Move delete support into FileOperationService.

Refactor Jobs to work with files from multiple providers.
Don't shut down threadpool until service#onDestroy is called.

Bug: 26696797, 26462789, 26567205, 25162803, 26714663
Change-Id: Id43e8e3dc2294cd07dcd6a3477b19efb298c260f
diff --git a/packages/DocumentsUI/res/values/strings.xml b/packages/DocumentsUI/res/values/strings.xml
index 016657e..05c43b2 100644
--- a/packages/DocumentsUI/res/values/strings.xml
+++ b/packages/DocumentsUI/res/values/strings.xml
@@ -87,7 +87,7 @@
     <!-- Button label that hides the error bar [CHAR LIMIT=24] -->
     <string name="button_dismiss">Dismiss</string>
     <string name="button_retry">Try Again</string>
-    
+
     <!-- Mode that sorts documents by their display name alphabetically [CHAR LIMIT=24] -->
     <string name="sort_name">By name</string>
     <!-- Mode that sorts documents by their last modified time in descending order; most recent first [CHAR LIMIT=24] -->
@@ -158,6 +158,8 @@
     <string name="copy_preparing">Preparing for copy\u2026</string>
     <!-- Text shown on the notification while DocumentsUI performs setup in preparation for moving files [CHAR LIMIT=32] -->
     <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>
     <!-- Title of the copy error notification [CHAR LIMIT=48] -->
     <plurals name="copy_error_notification_title">
         <item quantity="one">Couldn\'t copy <xliff:g id="count" example="1">%1$d</xliff:g> file</item>
@@ -168,6 +170,11 @@
         <item quantity="one">Couldn\'t move <xliff:g id="count" example="1">%1$d</xliff:g> file</item>
         <item quantity="other">Couldn\'t move <xliff:g id="count" example="2">%1$d</xliff:g> files</item>
     </plurals>
+    <!-- Title of the delete error notification [CHAR LIMIT=48] -->
+    <plurals name="delete_error_notification_title">
+        <item quantity="one">Couldn\'t delete <xliff:g id="count" example="1">%1$d</xliff:g> file</item>
+        <item quantity="other">Couldn\'t delete <xliff:g id="count" example="2">%1$d</xliff:g> files</item>
+    </plurals>
     <!-- Second line for notifications saying that more information will be shown after touching [CHAR LIMIT=48] -->
     <string name="notification_touch_for_details">Touch to view details</string>
     <!-- Label of a dialog button for retrying a failed operation [CHAR LIMIT=24] -->
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 580e2d8..6b9c90c 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -124,11 +124,13 @@
 
     public static final int REQUEST_COPY_DESTINATION = 1;
 
-    private static final String TAG = "DirectoryFragment";
-
-    private static final int LOADER_ID = 42;
     static final boolean DEBUG_ENABLE_DND = true;
 
+    private static final String TAG = "DirectoryFragment";
+    private static final int LOADER_ID = 42;
+    private static final int DELETE_UNDO_TIMEOUT = 5000;
+    private static final int DELETE_JOB_DELAY = 5500;
+
     private static final String EXTRA_TYPE = "type";
     private static final String EXTRA_ROOT = "root";
     private static final String EXTRA_DOC = "doc";
@@ -339,7 +341,7 @@
                     : MultiSelectManager.MODE_SINGLE);
         mSelectionManager.addCallback(new SelectionModeListener());
 
-        mModel = new Model(context);
+        mModel = new Model();
         mModel.addUpdateListener(mAdapter);
         mModel.addUpdateListener(mModelUpdateListener);
 
@@ -852,16 +854,32 @@
     }
 
     private void deleteDocuments(final Selection selected) {
-        Context context = getActivity();
-        String message = Shared.getQuantityString(context, R.plurals.deleting, selected.size());
 
-        // Hide the files in the UI.
-        final SparseArray<String> toDelete = mAdapter.hide(selected.getAll());
+            checkArgument(!selected.isEmpty());
+            new GetDocumentsTask() {
+                @Override
+                void onDocumentsReady(List<DocumentInfo> docs) {
+                    // Hide the files in the UI.
+                    final SparseArray<String> hidden = mAdapter.hide(selected.getAll());
+
+                    checkState(DELETE_JOB_DELAY > DELETE_UNDO_TIMEOUT);
+                    String operationId = FileOperations.delete(
+                            getActivity(), docs, getDisplayState().stack,
+                            DELETE_JOB_DELAY);
+                    showDeleteSnackbar(hidden, operationId);
+                }
+            }.execute(selected);
+    }
+
+    private void showDeleteSnackbar(final SparseArray<String> hidden, final String jobId) {
+
+        Context context = getActivity();
+        String message = Shared.getQuantityString(context, R.plurals.deleting, hidden.size());
 
         // Show a snackbar informing the user that files will be deleted, and give them an option to
         // cancel.
         final Activity activity = getActivity();
-        Snackbars.makeSnackbar(activity, message, Snackbar.LENGTH_LONG)
+        Snackbars.makeSnackbar(activity, message, DELETE_UNDO_TIMEOUT)
                 .setAction(
                         R.string.undo,
                         new View.OnClickListener() {
@@ -874,22 +892,8 @@
                             public void onDismissed(Snackbar snackbar, int event) {
                                 if (event == Snackbar.Callback.DISMISS_EVENT_ACTION) {
                                     // If the delete was cancelled, just unhide the files.
-                                    mAdapter.unhide(toDelete);
-                                } else {
-                                    // Actually kick off the delete.
-                                    mModel.delete(
-                                            selected,
-                                            new Model.DeletionListener() {
-                                                @Override
-                                                  public void onError() {
-                                                      Snackbars.makeSnackbar(
-                                                              activity,
-                                                              R.string.toast_failed_delete,
-                                                              Snackbar.LENGTH_LONG)
-                                                              .show();
-
-                                                  }
-                                            });
+                                    FileOperations.cancel(activity, jobId);
+                                    mAdapter.unhide(hidden);
                                 }
                             }
                         })
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
index cf21d15..075b3ea 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
@@ -24,11 +24,7 @@
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
 import static com.android.internal.util.Preconditions.checkNotNull;
 
-import android.content.ContentProviderClient;
-import android.content.ContentResolver;
-import android.content.Context;
 import android.database.Cursor;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Looper;
 import android.provider.DocumentsContract;
@@ -39,7 +35,6 @@
 
 import com.android.documentsui.BaseActivity.SiblingProvider;
 import com.android.documentsui.DirectoryResult;
-import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.RootCursorWrapper;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.model.DocumentInfo;
@@ -56,7 +51,6 @@
 public class Model implements SiblingProvider {
     private static final String TAG = "Model";
 
-    private Context mContext;
     private boolean mIsLoading;
     private List<UpdateListener> mUpdateListeners = new ArrayList<>();
     @Nullable private Cursor mCursor;
@@ -73,10 +67,6 @@
     @Nullable String info;
     @Nullable String error;
 
-    Model(Context context) {
-        mContext = context;
-    }
-
     /**
      * Generates a Model ID for a cursor entry that refers to a document. The Model ID is a unique
      * string that can be used to identify the document referred to by the cursor.
@@ -406,91 +396,6 @@
         return mCursor;
     }
 
-    public void delete(Selection selected, DeletionListener listener) {
-        final ContentResolver resolver = mContext.getContentResolver();
-        new DeleteFilesTask(resolver, listener).execute(selected);
-    }
-
-    /**
-     * A Task which collects the DocumentInfo for documents that have been marked for deletion,
-     * and actually deletes them.
-     */
-    private class DeleteFilesTask extends AsyncTask<Selection, Void, Void> {
-        private ContentResolver mResolver;
-        private DeletionListener mListener;
-        private boolean mHadTrouble;
-
-        /**
-         * @param resolver A ContentResolver for performing the actual file deletions.
-         * @param errorCallback A Runnable that is executed in the event that one or more errors
-         *     occurred while copying files.  Execution will occur on the UI thread.
-         */
-        public DeleteFilesTask(ContentResolver resolver, DeletionListener listener) {
-            mResolver = resolver;
-            mListener = listener;
-        }
-
-        @Override
-        protected Void doInBackground(Selection... selected) {
-            List<DocumentInfo> toDelete = null;
-            try {
-                toDelete = getDocuments(selected[0]);
-            } catch (NullPointerException e) {
-                Log.w(TAG, "Failed to retrieve documents for delete.");
-                mHadTrouble = true;
-                return null;
-            }
-
-            for (DocumentInfo doc : toDelete) {
-                if (!doc.isDeleteSupported()) {
-                    Log.w(TAG, doc + " could not be deleted.  Skipping...");
-                    mHadTrouble = true;
-                    continue;
-                }
-
-                ContentProviderClient client = null;
-                try {
-                    if (DEBUG) Log.d(TAG, "Deleting: " + doc.displayName);
-                    client = DocumentsApplication.acquireUnstableProviderOrThrow(
-                        mResolver, doc.derivedUri.getAuthority());
-                    DocumentsContract.deleteDocument(client, doc.derivedUri);
-                } catch (Exception e) {
-                    Log.w(TAG, "Failed to delete " + doc, e);
-                    mHadTrouble = true;
-                } finally {
-                    ContentProviderClient.releaseQuietly(client);
-                }
-            }
-
-            return null;
-        }
-
-        @Override
-        protected void onPostExecute(Void _) {
-            if (mHadTrouble) {
-                // TODO show which files failed? b/23720103
-                mListener.onError();
-                if (DEBUG) Log.d(TAG, "Deletion task completed.  Some deletions failed.");
-            } else {
-                if (DEBUG) Log.d(TAG, "Deletion task completed successfully.");
-            }
-
-            mListener.onCompletion();
-        }
-    }
-
-    static class DeletionListener {
-        /**
-         * Called when deletion has completed (regardless of whether an error occurred).
-         */
-        void onCompletion() {}
-
-        /**
-         * Called at the end of a deletion operation that produced one or more errors.
-         */
-        void onError() {}
-    }
-
     void addUpdateListener(UpdateListener listener) {
         mUpdateListeners.add(listener);
     }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
index b1932b8..f3195a7 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
@@ -21,7 +21,6 @@
 import static android.provider.DocumentsContract.buildDocumentUri;
 import static android.provider.DocumentsContract.getDocumentId;
 import static android.provider.DocumentsContract.isChildDocument;
-import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow;
 import static com.android.documentsui.Shared.DEBUG;
 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
@@ -62,12 +61,7 @@
 class CopyJob extends Job {
     private static final String TAG = "CopyJob";
     private static final int PROGRESS_INTERVAL_MILLIS = 1000;
-    final List<DocumentInfo> mSrcFiles;
-
-    // Provider clients are acquired for the duration of each copy job. Note that there is an
-    // implicit assumption that all srcs come from the same authority.
-    ContentProviderClient srcClient;
-    ContentProviderClient dstClient;
+    final List<DocumentInfo> mSrcs;
 
     private long mStartTime = -1;
     private long mBatchSize;
@@ -86,11 +80,11 @@
      * @param srcs List of files to be copied.
      */
     CopyJob(Context service, Context appContext, Listener listener,
-            String id, DocumentStack destination, List<DocumentInfo> srcs) {
-        super(service, appContext, listener, OPERATION_COPY, id, destination);
+            String id, DocumentStack stack, List<DocumentInfo> srcs) {
+        super(service, appContext, listener, OPERATION_COPY, id, stack);
 
         checkArgument(!srcs.isEmpty());
-        this.mSrcFiles = srcs;
+        this.mSrcs = srcs;
     }
 
     /**
@@ -103,7 +97,7 @@
         super(service, appContext, listener, opType, id, destination);
 
         checkArgument(!srcs.isEmpty());
-        this.mSrcFiles = srcs;
+        this.mSrcs = srcs;
     }
 
     @Override
@@ -185,21 +179,13 @@
     void start() throws RemoteException {
         mStartTime = elapsedRealtime();
 
-        // Acquire content providers.
-        srcClient = acquireUnstableProviderOrThrow(
-                getContentResolver(),
-                mSrcFiles.get(0).authority);
-        dstClient = acquireUnstableProviderOrThrow(
-                getContentResolver(),
-                stack.peek().authority);
-
         // client
-        mBatchSize = calculateSize(srcClient, mSrcFiles);
+        mBatchSize = calculateSize(mSrcs);
 
         DocumentInfo srcInfo;
         DocumentInfo dstInfo;
-        for (int i = 0; i < mSrcFiles.size() && !isCanceled(); ++i) {
-            srcInfo = mSrcFiles.get(i);
+        for (int i = 0; i < mSrcs.size() && !isCanceled(); ++i) {
+            srcInfo = mSrcs.get(i);
             dstInfo = stack.peek();
 
             // Guard unsupported recursive operation.
@@ -233,24 +219,24 @@
     /**
      * Copies a the given document to the given location.
      *
-     * @param srcInfo DocumentInfos for the documents to copy.
+     * @param src DocumentInfos for the documents to copy.
      * @param dstDirInfo The destination directory.
      * @return True on success, false on failure.
      * @throws RemoteException
      */
-    boolean processDocument(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException {
+    boolean processDocument(DocumentInfo src, DocumentInfo dstDirInfo) throws RemoteException {
 
         // TODO: When optimized copy kicks in, we'll not making any progress updates.
         // For now. Local storage isn't using optimized copy.
 
         // When copying within the same provider, try to use optimized copying.
         // If not supported, then fallback to byte-by-byte copy/move.
-        if (srcInfo.authority.equals(dstDirInfo.authority)) {
-            if ((srcInfo.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
-                if (DocumentsContract.copyDocument(srcClient, srcInfo.derivedUri,
+        if (src.authority.equals(dstDirInfo.authority)) {
+            if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
+                if (DocumentsContract.copyDocument(getClient(src), src.derivedUri,
                         dstDirInfo.derivedUri) == null) {
-                    onFileFailed(srcInfo,
-                            "Provider side copy failed for documents: " + srcInfo.derivedUri + ".");
+                    onFileFailed(src,
+                            "Provider side copy failed for documents: " + src.derivedUri + ".");
                     return false;
                 }
                 return true;
@@ -258,44 +244,44 @@
         }
 
         // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
-        return byteCopyDocument(srcInfo, dstDirInfo);
+        return byteCopyDocument(src, dstDirInfo);
     }
 
-    boolean byteCopyDocument(DocumentInfo srcInfo, DocumentInfo dstDirInfo)
+    boolean byteCopyDocument(DocumentInfo src, DocumentInfo dest)
             throws RemoteException {
         final String dstMimeType;
         final String dstDisplayName;
 
-        if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + srcInfo);
+        if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src);
         // If the file is virtual, but can be converted to another format, then try to copy it
         // as such format. Also, append an extension for the target mime type (if known).
-        if (srcInfo.isVirtualDocument()) {
+        if (src.isVirtualDocument()) {
             final String[] streamTypes = getContentResolver().getStreamTypes(
-                    srcInfo.derivedUri, "*/*");
+                    src.derivedUri, "*/*");
             if (streamTypes != null && streamTypes.length > 0) {
                 dstMimeType = streamTypes[0];
                 final String extension = MimeTypeMap.getSingleton().
                         getExtensionFromMimeType(dstMimeType);
-                dstDisplayName = srcInfo.displayName +
-                        (extension != null ? "." + extension : srcInfo.displayName);
+                dstDisplayName = src.displayName +
+                        (extension != null ? "." + extension : src.displayName);
             } else {
-                onFileFailed(srcInfo, "Cannot copy virtual file. No streamable formats available.");
+                onFileFailed(src, "Cannot copy virtual file. No streamable formats available.");
                 return false;
             }
         } else {
-            dstMimeType = srcInfo.mimeType;
-            dstDisplayName = srcInfo.displayName;
+            dstMimeType = src.mimeType;
+            dstDisplayName = src.displayName;
         }
 
         // Create the target document (either a file or a directory), then copy recursively the
         // contents (bytes or children).
-        final Uri dstUri = DocumentsContract.createDocument(dstClient,
-                dstDirInfo.derivedUri, dstMimeType, dstDisplayName);
+        final Uri dstUri = DocumentsContract.createDocument(
+                getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName);
         if (dstUri == null) {
             // If this is a directory, the entire subdir will not be copied over.
-            onFileFailed(srcInfo,
+            onFileFailed(src,
                     "Couldn't create destination document " + dstDisplayName
-                    + " in directory " + dstDirInfo.displayName + ".");
+                    + " in directory " + dest.displayName + ".");
             return false;
         }
 
@@ -303,16 +289,16 @@
         try {
             dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
         } catch (FileNotFoundException e) {
-            onFileFailed(srcInfo,
+            onFileFailed(src,
                     "Could not load DocumentInfo for newly created file: " + dstUri + ".");
             return false;
         }
 
         final boolean success;
-        if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) {
-            success = copyDirectoryHelper(srcInfo, dstInfo);
+        if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
+            success = copyDirectoryHelper(src, dstInfo);
         } else {
-            success = copyFileHelper(srcInfo, dstInfo, dstMimeType);
+            success = copyFileHelper(src, dstInfo, dstMimeType);
         }
 
         return success;
@@ -322,13 +308,13 @@
      * Handles recursion into a directory and copying its contents. Note that in linux terms, this
      * does the equivalent of "cp src/* dst", not "cp -r src dst".
      *
-     * @param srcDirInfo Info of the directory to copy from. The routine will copy the directory's
+     * @param srcDir Info of the directory to copy from. The routine will copy the directory's
      *            contents, not the directory itself.
-     * @param dstDirInfo Info of the directory to copy to. Must be created beforehand.
+     * @param destDir Info of the directory to copy to. Must be created beforehand.
      * @return True on success, false if some of the children failed to copy.
      * @throws RemoteException
      */
-    private boolean copyDirectoryHelper(DocumentInfo srcDirInfo, DocumentInfo dstDirInfo)
+    private boolean copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir)
             throws RemoteException {
         // Recurse into directories. Copy children into the new subdirectory.
         final String queryColumns[] = new String[] {
@@ -342,13 +328,11 @@
         boolean success = true;
         try {
             // Iterate over srcs in the directory; copy to the destination directory.
-            final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirInfo.authority,
-                    srcDirInfo.documentId);
-            cursor = srcClient.query(queryUri, queryColumns, null, null, null);
-            DocumentInfo srcInfo;
+            final Uri queryUri = buildChildDocumentsUri(srcDir.authority, srcDir.documentId);
+            cursor = getClient(srcDir).query(queryUri, queryColumns, null, null, null);
             while (cursor.moveToNext() && !isCanceled()) {
-                srcInfo = DocumentInfo.fromCursor(cursor, srcDirInfo.authority);
-                success &= processDocument(srcInfo, dstDirInfo);
+                DocumentInfo src = DocumentInfo.fromCursor(cursor, srcDir.authority);
+                success &= processDocument(src, destDir);
             }
         } finally {
             IoUtils.closeQuietly(cursor);
@@ -366,50 +350,49 @@
      * @return True on success, false on error.
      * @throws RemoteException
      */
-    private boolean copyFileHelper(DocumentInfo srcInfo, DocumentInfo dstInfo, String mimeType)
+    private boolean copyFileHelper(DocumentInfo src, DocumentInfo dest, String mimeType)
             throws RemoteException {
-        // Copy an individual file.
         CancellationSignal canceller = new CancellationSignal();
         ParcelFileDescriptor srcFile = null;
         ParcelFileDescriptor dstFile = null;
-        InputStream src = null;
-        OutputStream dst = null;
+        InputStream in = null;
+        OutputStream out = null;
 
         boolean success = true;
         try {
             // If the file is virtual, but can be converted to another format, then try to copy it
             // as such format.
-            if (srcInfo.isVirtualDocument()) {
+            if (src.isVirtualDocument()) {
                 final AssetFileDescriptor srcFileAsAsset =
-                        srcClient.openTypedAssetFileDescriptor(
-                                srcInfo.derivedUri, mimeType, null, canceller);
+                        getClient(src).openTypedAssetFileDescriptor(
+                                src.derivedUri, mimeType, null, canceller);
                 srcFile = srcFileAsAsset.getParcelFileDescriptor();
-                src = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
+                in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
             } else {
-                srcFile = srcClient.openFile(srcInfo.derivedUri, "r", canceller);
-                src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
+                srcFile = getClient(src).openFile(src.derivedUri, "r", canceller);
+                in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
             }
 
-            dstFile = dstClient.openFile(dstInfo.derivedUri, "w", canceller);
-            dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
+            dstFile = getClient(dest).openFile(dest.derivedUri, "w", canceller);
+            out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
 
             byte[] buffer = new byte[32 * 1024];
             int len;
-            while ((len = src.read(buffer)) != -1) {
+            while ((len = in.read(buffer)) != -1) {
                 if (isCanceled()) {
                     if (DEBUG) Log.d(TAG, "Canceled copy mid-copy. Id:" + id);
                     success = false;
                     break;
                 }
-                dst.write(buffer, 0, len);
+                out.write(buffer, 0, len);
                 makeCopyProgress(len);
             }
 
             srcFile.checkError();
         } catch (IOException e) {
             success = false;
-            onFileFailed(srcInfo, "Exception thrown while copying from "
-                    + srcInfo.derivedUri + " to " + dstInfo.derivedUri + ".");
+            onFileFailed(src, "Exception thrown while copying from "
+                    + src.derivedUri + " to " + dest.derivedUri + ".");
 
             if (dstFile != null) {
                 try {
@@ -420,20 +403,20 @@
             }
         } finally {
             // This also ensures the file descriptors are closed.
-            IoUtils.closeQuietly(src);
-            IoUtils.closeQuietly(dst);
+            IoUtils.closeQuietly(in);
+            IoUtils.closeQuietly(out);
         }
 
         if (!success) {
             if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers.");
             canceller.cancel();
             try {
-                DocumentsContract.deleteDocument(dstClient, dstInfo.derivedUri);
+                DocumentsContract.deleteDocument(getClient(dest), dest.derivedUri);
             } catch (RemoteException e) {
                 // RemoteExceptions usually signal that the connection is dead, so there's no
                 // point attempting to continue. Propagate the exception up so the copy job is
                 // cancelled.
-                Log.w(TAG, "Failed to cleanup after copy error: " + srcInfo.derivedUri, e);
+                Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e);
                 throw e;
             }
         }
@@ -449,14 +432,14 @@
      * @return Size in bytes.
      * @throws RemoteException
      */
-    private static long calculateSize(ContentProviderClient client, List<DocumentInfo> srcs)
+    private long calculateSize(List<DocumentInfo> srcs)
             throws RemoteException {
         long result = 0;
 
         for (DocumentInfo src : srcs) {
             if (src.isDirectory()) {
                 // Directories need to be recursed into.
-                result += calculateFileSizesRecursively(client, src.derivedUri);
+                result += calculateFileSizesRecursively(getClient(src), src.derivedUri);
             } else {
                 result += src.size;
             }
@@ -503,20 +486,14 @@
         return result;
     }
 
-    @Override
-    void cleanup() {
-        ContentProviderClient.releaseQuietly(srcClient);
-        ContentProviderClient.releaseQuietly(dstClient);
-    }
-
     /**
      * Returns true if {@code doc} is a descendant of {@code parentDoc}.
      * @throws RemoteException
      */
-    boolean isDescendentOf(DocumentInfo doc, DocumentInfo parentDoc)
+    boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent)
             throws RemoteException {
-        if (parentDoc.isDirectory() && doc.authority.equals(parentDoc.authority)) {
-            return isChildDocument(dstClient, doc.derivedUri, parentDoc.derivedUri);
+        if (parent.isDirectory() && doc.authority.equals(parent.authority)) {
+            return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri);
         }
         return false;
     }
@@ -525,4 +502,16 @@
         Log.w(TAG, msg);
         onFileFailed(file);
     }
+
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append("CopyJob")
+                .append("{")
+                .append("id=" + id)
+                .append("srcs=" + mSrcs)
+                .append(", destination=" + stack)
+                .append("}")
+                .toString();
+    }
 }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java
new file mode 100644
index 0000000..6a2a794
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java
@@ -0,0 +1,96 @@
+/*
+ * 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.services;
+
+import static com.android.documentsui.Shared.DEBUG;
+import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE;
+
+import android.app.Notification;
+import android.app.Notification.Builder;
+import android.content.Context;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.documentsui.R;
+import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.model.DocumentStack;
+
+import java.util.List;
+
+final class DeleteJob extends Job {
+
+    private static final String TAG = "DeleteJob";
+    private List<DocumentInfo> mSrcs;
+
+    /**
+     * Moves files to a destination identified by {@code destination}.
+     * Performs most work by delegating to CopyJob, then deleting
+     * a file after it has been copied.
+     *
+     * @see @link {@link Job} constructor for most param descriptions.
+     *
+     * @param srcs List of files to delete
+     */
+    DeleteJob(Context service, Context appContext, Listener listener,
+            String id, DocumentStack stack, List<DocumentInfo> srcs) {
+        super(service, appContext, listener, OPERATION_DELETE, id, stack);
+        this.mSrcs = srcs;
+    }
+
+    @Override
+    Builder createProgressBuilder() {
+        return super.createProgressBuilder(
+                service.getString(R.string.move_notification_title),
+                R.drawable.ic_menu_copy,
+                service.getString(android.R.string.cancel),
+                R.drawable.ic_cab_cancel);
+    }
+
+    @Override
+    public Notification getSetupNotification() {
+        return getSetupNotification(service.getString(R.string.delete_preparing));
+    }
+
+    @Override
+    Notification getFailureNotification() {
+        return getFailureNotification(
+                R.plurals.delete_error_notification_title, R.drawable.ic_menu_delete);
+    }
+
+    @Override
+    void start() throws RemoteException {
+        for (DocumentInfo doc : mSrcs) {
+            if (DEBUG) Log.d(TAG, "Deleting document @ " + doc.derivedUri);
+            if (!deleteDocument(doc)) {
+                Log.w(TAG, "Failed to delete document @ " + doc.derivedUri);
+                onFileFailed(doc);
+            }
+        }
+    }
+
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append("DeleteJob")
+                .append("{")
+                .append("id=" + id)
+                .append("srcs=" + mSrcs)
+                .append(", location=" + stack)
+                .append("}")
+                .toString();
+    }
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
index 1df20ac..d647950 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
@@ -27,6 +27,7 @@
 import android.content.Intent;
 import android.os.IBinder;
 import android.os.PowerManager;
+import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 import android.util.Log;
 
@@ -92,7 +93,7 @@
     @GuardedBy("mRunning")
     private Map<String, JobRecord> mRunning = new HashMap<>();
 
-    private int mLastStarted;
+    private int mLastServiceId;
 
     @Override
     public void onCreate() {
@@ -111,7 +112,18 @@
     }
 
     @Override
-    public int onStartCommand(Intent intent, int flags, int startTime) {
+    public void onDestroy() {
+        if (DEBUG) Log.d(TAG, "Shutting down executor.");
+        List<Runnable> unfinished = executor.shutdownNow();
+        if (!unfinished.isEmpty()) {
+            Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished);
+        }
+        executor = null;
+        if (DEBUG) Log.d(TAG, "Destroyed.");
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int serviceId) {
         // TODO: Ensure we're not being called with retry or redeliver.
         // checkArgument(flags == 0);  // retry and redeliver are not supported.
 
@@ -123,17 +135,17 @@
             handleCancel(intent);
         } else {
             checkArgument(operationType != OPERATION_UNKNOWN);
-            handleOperation(intent, startTime, jobId, operationType);
+            handleOperation(intent, serviceId, jobId, operationType);
         }
 
         return START_NOT_STICKY;
     }
 
-    private void handleOperation(Intent intent, int startTime, String jobId, int operationType) {
-        if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with start time " + startTime);
+    private void handleOperation(Intent intent, int serviceId, String jobId, int operationType) {
+        if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId);
 
-        // Track start time so we can stop the service once we're out of work to do.
-        mLastStarted = startTime;
+        // 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) {
@@ -147,12 +159,18 @@
 
             job = createJob(operationType, jobId, srcs, stack);
 
+            if (job == null) {
+                return;
+            }
+
             mWakeLock.acquire();
         }
 
         checkState(job != null);
         int delay = intent.getIntExtra(EXTRA_DELAY, DEFAULT_DELAY);
         checkArgument(delay <= MAX_DELAY);
+        if (DEBUG) Log.d(
+                TAG, "Scheduling job " + job.id + " to run in " + delay + " milliseconds.");
         ScheduledFuture<?> future = executor.schedule(job, delay, TimeUnit.MILLISECONDS);
         mRunning.put(jobId, new JobRecord(job, future));
     }
@@ -196,11 +214,19 @@
         // TODO: Guarantee the job is being finalized
     }
 
+    /**
+     * Creates a new job. Returns null if a job with {@code id} already exists.
+     * @return
+     */
     @GuardedBy("mRunning")
-    private Job createJob(
+    private @Nullable Job createJob(
             @OpType int operationType, String id, List<DocumentInfo> srcs, DocumentStack stack) {
 
-        checkArgument(!mRunning.containsKey(id));
+        if (mRunning.containsKey(id)) {
+            Log.w(TAG, "Duplicate job id: " + id
+                    + ". Ignoring job request for srcs: " + srcs + ", stack: " + stack + ".");
+            return null;
+        }
 
         Job job = null;
         switch (operationType) {
@@ -211,7 +237,8 @@
                 job = jobFactory.createMove(this, getApplicationContext(), this, id, stack, srcs);
                 break;
             case OPERATION_DELETE:
-                throw new UnsupportedOperationException();
+                job = jobFactory.createDelete(this, getApplicationContext(), this, id, stack, srcs);
+                break;
             default:
                 throw new UnsupportedOperationException();
         }
@@ -234,20 +261,21 @@
 
     /**
      * Most likely shuts down. Won't shut down if service has a pending
-     * message.
+     * message. Thread pool is deal with in onDestroy.
      */
     private void shutdown() {
-        if (DEBUG) Log.d(TAG, "Shutting down. Last start time: " + mLastStarted);
+        if (DEBUG) Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId);
         mWakeLock.release();
         mWakeLock = null;
-        boolean gonnaStop = stopSelfResult(mLastStarted);
+
+        // Turns out, for us, stopSelfResult always returns false in tests,
+        // so we can't guard executor shutdown. For this reason we move
+        // executor shutdown to #onDestroy.
+        boolean gonnaStop = stopSelfResult(mLastServiceId);
         if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop);
         if (!gonnaStop) {
             Log.w(TAG, "Service should be stopping, but reports otherwise.");
         }
-        // Sadly "gonnaStop" is always false in tests, so we can't guard executor shutdown.
-        List<Runnable> unfinished = executor.shutdownNow();
-        checkState(unfinished.isEmpty());
     }
 
     @VisibleForTesting
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java
index 0f1730a3..f59a32a 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java
@@ -22,6 +22,7 @@
 import static com.android.documentsui.Shared.asArrayList;
 import static com.android.documentsui.Shared.getQuantityString;
 import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL;
+import static com.android.documentsui.services.FileOperationService.EXTRA_DELAY;
 import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID;
 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
 import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
@@ -52,10 +53,12 @@
 
     private static final String TAG = "FileOperations";
 
+    private static final IdBuilder idBuilder = new IdBuilder();
+
     private FileOperations() {}
 
     public static String createJobId() {
-        return String.valueOf(elapsedRealtime());
+        return idBuilder.getNext();
     }
 
     /**
@@ -73,7 +76,7 @@
             case OPERATION_MOVE:
                 return FileOperations.move(activity, srcDocs, stack);
             case OPERATION_DELETE:
-                return FileOperations.delete(activity, srcDocs, stack);
+                throw new UnsupportedOperationException("Delete isn't currently supported.");
             default:
                 throw new UnsupportedOperationException("Unknown operation: " + operationType);
         }
@@ -151,14 +154,17 @@
      * @param jobId A unique jobid for this job.
      *     Use {@link #createJobId} if you don't have one handy.
      * @param srcDocs A list of src files to copy.
+     * @param delay Number of milliseconds to wait before executing the job.
      * @return Id of the job.
      */
     public static String delete(
-            Activity activity, List<DocumentInfo> srcDocs, DocumentStack location) {
+            Activity activity, List<DocumentInfo> srcDocs, DocumentStack location, int delay) {
         String jobId = createJobId();
-        if (DEBUG) Log.d(TAG, "Initiating 'delete' operation id: " + jobId);
+        if (DEBUG) Log.d(TAG, "Initiating 'delete' operation id " + jobId
+                + " delayed by " + delay + " milliseconds.");
 
         Intent intent = createBaseIntent(OPERATION_DELETE, activity, jobId, srcDocs, location);
+        intent.putExtra(EXTRA_DELAY, delay);
         activity.startService(intent);
 
         return jobId;
@@ -193,4 +199,24 @@
                 getQuantityString(activity, contentId, fileCount),
                 Snackbar.LENGTH_SHORT);
     }
+
+    private static final class IdBuilder {
+
+        // Remember last job time so we can guard against collisions.
+        private long mLastJobTime;
+
+        // If we detect a collision, use subId to make distinct.
+        private int mSubId;
+
+        public synchronized String getNext() {
+            long time = elapsedRealtime();
+            if (time == mLastJobTime) {
+                mSubId++;
+            } else {
+                mSubId = 0;
+            }
+            mLastJobTime = time;
+            return String.valueOf(mLastJobTime) + "-" + String.valueOf(mSubId);
+        }
+    }
 }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
index c7939eb..f351df9 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui.services;
 
+import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow;
 import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL;
 import static com.android.documentsui.services.FileOperationService.EXTRA_FAILURE;
 import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID;
@@ -24,18 +25,21 @@
 import static com.android.documentsui.services.FileOperationService.FAILURE_COPY;
 import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN;
 import static com.android.internal.util.Preconditions.checkArgument;
+import static com.android.internal.util.Preconditions.checkNotNull;
 
 import android.annotation.DrawableRes;
 import android.annotation.PluralsRes;
 import android.app.Notification;
 import android.app.Notification.Builder;
 import android.app.PendingIntent;
+import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Parcelable;
 import android.os.RemoteException;
 import android.provider.DocumentsContract;
+import android.util.Log;
 
 import com.android.documentsui.FilesActivity;
 import com.android.documentsui.R;
@@ -45,7 +49,9 @@
 import com.android.documentsui.services.FileOperationService.OpType;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * A mashup of work item and ui progress update factory. Used by {@link FileOperationService}
@@ -53,6 +59,7 @@
  */
 abstract class Job implements Runnable {
 
+    private static final String TAG = "Job";
     final Context service;
     final Context appContext;
     final Listener listener;
@@ -64,6 +71,7 @@
     final ArrayList<DocumentInfo> failedFiles = new ArrayList<>();
     final Notification.Builder mProgressBuilder;
 
+    private final Map<String, ContentProviderClient> mClients = new HashMap<>();
     private volatile boolean mCanceled;
 
     /**
@@ -118,14 +126,31 @@
 
     abstract void start() throws RemoteException;
 
-    // Service will call this when it is done with the job.
-    abstract void cleanup();
-
     abstract Notification getSetupNotification();
     // TODO: Progress notification for deletes.
     // abstract Notification getProgressNotification(long bytesCopied);
     abstract Notification getFailureNotification();
 
+    ContentProviderClient getClient(DocumentInfo doc) throws RemoteException {
+        ContentProviderClient client = mClients.get(doc.authority);
+        if (client == null) {
+            // Acquire content providers.
+            client = acquireUnstableProviderOrThrow(
+                    getContentResolver(),
+                    doc.authority);
+
+            mClients.put(doc.authority, client);
+        }
+
+        return checkNotNull(client);
+    }
+
+    final void cleanup() {
+        for (ContentProviderClient client : mClients.values()) {
+            ContentProviderClient.releaseQuietly(client);
+        }
+    }
+
     final void cancel() {
         mCanceled = true;
     }
@@ -146,6 +171,17 @@
         return !failedFiles.isEmpty();
     }
 
+    final boolean deleteDocument(DocumentInfo doc) {
+        try {
+            DocumentsContract.deleteDocument(getClient(doc), doc.derivedUri);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Failed to delete file: " + doc.derivedUri, e);
+            return false;
+        }
+
+        return true;  // victory dance!
+    }
+
     Notification getSetupNotification(String content) {
         mProgressBuilder.setProgress(0, 0, true);
         mProgressBuilder.setContentText(content);
@@ -215,6 +251,16 @@
         return cancelIntent;
     }
 
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append("Job")
+                .append("{")
+                .append("id=" + id)
+                .append("}")
+                .toString();
+    }
+
     /**
      * Factory class that facilitates our testing FileOperationService.
      */
@@ -231,6 +277,11 @@
                 String id, DocumentStack stack, List<DocumentInfo> srcs) {
             return new MoveJob(service, appContext, listener, id, stack, srcs);
         }
+
+        Job createDelete(Context service, Context appContext, Listener listener,
+                String id, DocumentStack stack, List<DocumentInfo> srcs) {
+            return new DeleteJob(service, appContext, listener, id, stack, srcs);
+        }
     }
 
     /**
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java
index f46f234..7f6b1e1 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java
@@ -24,7 +24,6 @@
 import android.os.RemoteException;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Document;
-import android.util.Log;
 
 import com.android.documentsui.R;
 import com.android.documentsui.model.DocumentInfo;
@@ -76,17 +75,17 @@
     }
 
     @Override
-    boolean processDocument(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException {
+    boolean processDocument(DocumentInfo src, DocumentInfo dest) throws RemoteException {
 
         // TODO: When optimized move kicks in, we're not making any progress updates. FIX IT!
 
         // When moving within the same provider, try to use optimized moving.
         // If not supported, then fallback to byte-by-byte copy/move.
-        if (srcInfo.authority.equals(dstDirInfo.authority)) {
-            if ((srcInfo.flags & Document.FLAG_SUPPORTS_MOVE) != 0) {
-                if (DocumentsContract.moveDocument(srcClient, srcInfo.derivedUri,
-                        dstDirInfo.derivedUri) == null) {
-                    onFileFailed(srcInfo);
+        if (src.authority.equals(dest.authority)) {
+            if ((src.flags & Document.FLAG_SUPPORTS_MOVE) != 0) {
+                if (DocumentsContract.moveDocument(getClient(src), src.derivedUri,
+                        dest.derivedUri) == null) {
+                    onFileFailed(src);
                     return false;
                 }
                 return true;
@@ -94,22 +93,20 @@
         }
 
         // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
-        boolean copied = byteCopyDocument(srcInfo, dstDirInfo);
+        boolean copied = byteCopyDocument(src, dest);
 
-        return copied && !isCanceled() && deleteSrcDocument(srcInfo);
+        return copied && !isCanceled() && deleteDocument(src);
     }
 
-    private boolean deleteSrcDocument(DocumentInfo srcInfo) {
-        // This is racey. We should make sure that we never delete a directory after
-        // it changed, so we don't remove a file which had not been copied earlier
-        // to the target location.
-        try {
-            DocumentsContract.deleteDocument(srcClient, srcInfo.derivedUri);
-        } catch (RemoteException e) {
-            Log.w(TAG, "Failed to delete source after copy: " + srcInfo.derivedUri, e);
-            return false;
-        }
-
-        return true;  // victory dance!
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append("MoveJob")
+                .append("{")
+                .append("id=" + id)
+                .append("srcs=" + mSrcs)
+                .append(", destination=" + stack)
+                .append("}")
+                .toString();
     }
 }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java
index 5ce1823..2244be9 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java
@@ -51,7 +51,7 @@
     public void setUp() {
 
         final Context testContext = TestContext.createStorageTestContext(getContext(), AUTHORITY);
-        mModel = new TestModel(testContext, AUTHORITY);
+        mModel = new TestModel(AUTHORITY);
         mModel.update(NAMES);
 
         DocumentsAdapter.Environment env = new TestEnvironment(testContext);
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelTest.java
index a5f0656..83299f0 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/ModelTest.java
@@ -38,7 +38,6 @@
 import java.util.List;
 import java.util.Random;
 import java.util.Set;
-import java.util.concurrent.CountDownLatch;
 
 @SmallTest
 public class ModelTest extends AndroidTestCase {
@@ -96,7 +95,7 @@
         r.cursor = cursor;
 
         // Instantiate the model with a dummy view adapter and listener that (for now) do nothing.
-        model = new Model(context);
+        model = new Model();
         model.addUpdateListener(new DummyListener());
         model.update(r);
     }
@@ -303,16 +302,6 @@
         }
     }
 
-    // Tests that Model.delete works correctly.
-    public void testDelete() throws Exception {
-        // Simulate deleting 2 files.
-        List<DocumentInfo> docsBefore = getDocumentInfo(2, 3);
-        delete(2, 3);
-
-        provider.assertWasDeleted(docsBefore.get(0));
-        provider.assertWasDeleted(docsBefore.get(1));
-    }
-
     private void setupTestContext() {
         final MockContentResolver resolver = new MockContentResolver();
         context = new ContextWrapper(getContext()) {
@@ -335,29 +324,6 @@
         return s;
     }
 
-    private void delete(int... positions) throws InterruptedException {
-        Selection s = positionToSelection(positions);
-        final CountDownLatch latch = new CountDownLatch(1);
-
-        model.delete(
-                s,
-                new Model.DeletionListener() {
-                    @Override
-                    public void onError() {
-                        latch.countDown();
-                    }
-                    @Override
-                    void onCompletion() {
-                        latch.countDown();
-                    }
-                });
-        latch.await();
-    }
-
-    private List<DocumentInfo> getDocumentInfo(int... positions) {
-        return model.getDocuments(positionToSelection(positions));
-    }
-
     private static class DummyListener implements Model.UpdateListener {
         public void onModelUpdate(Model model) {}
         public void onModelUpdateFailed(Exception e) {}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
index 398885c..7c324e7 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
@@ -23,15 +23,12 @@
 import android.support.v7.widget.RecyclerView;
 import android.test.AndroidTestCase;
 import android.test.suitebuilder.annotation.SmallTest;
-import android.util.SparseArray;
 import android.view.ViewGroup;
 
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.RootCursorWrapper;
 import com.android.documentsui.State;
 
-import java.util.List;
-
 @SmallTest
 public class SectionBreakDocumentsAdapterWrapperTest extends AndroidTestCase {
 
@@ -57,7 +54,7 @@
         final Context testContext = TestContext.createStorageTestContext(getContext(), AUTHORITY);
         DocumentsAdapter.Environment env = new TestEnvironment(testContext);
 
-        mModel = new TestModel(testContext, AUTHORITY);
+        mModel = new TestModel(AUTHORITY);
         mAdapter = new SectionBreakDocumentsAdapterWrapper(
             env,
             new ModelBackedDocumentsAdapter(
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestModel.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestModel.java
index f9cd3b2..d8c29db 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestModel.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestModel.java
@@ -16,16 +16,13 @@
 
 package com.android.documentsui.dirlist;
 
-import android.content.Context;
 import android.database.MatrixCursor;
 import android.provider.DocumentsContract.Document;
 
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.RootCursorWrapper;
-import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 
 import java.util.Random;
-import java.util.Set;
 
 public class TestModel extends Model {
 
@@ -39,14 +36,9 @@
     };
 
     private final String mAuthority;
-    private Set<String> mDeleted;
 
-    /**
-     * Creates a new context. context must be configured with provider for authority.
-     * @see TestContext#createStorageTestContext(Context, String).
-     */
-    public TestModel(Context context, String authority) {
-        super(context);
+    public TestModel(String authority) {
+        super();
         mAuthority = authority;
     }
 
@@ -75,12 +67,4 @@
     String idForPosition(int p) {
         return createModelId(mAuthority, Integer.toString(p));
     }
-
-    @Override
-    public void delete(Selection selected, DeletionListener listener) {
-        for (String id : selected.getAll()) {
-            mDeleted.add(id);
-        }
-        listener.onCompletion();
-    }
 }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/BaseCopyJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java
similarity index 72%
rename from packages/DocumentsUI/tests/src/com/android/documentsui/services/BaseCopyJobTest.java
rename to packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java
index f57ce53..ec21150 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/BaseCopyJobTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java
@@ -16,72 +16,18 @@
 
 package com.android.documentsui.services;
 
-import static com.android.documentsui.StubProvider.ROOT_0_ID;
-import static com.android.documentsui.StubProvider.ROOT_1_ID;
 import static com.google.common.collect.Lists.newArrayList;
 
-import android.content.ContentProviderClient;
-import android.content.ContentResolver;
-import android.content.Context;
 import android.net.Uri;
-import android.os.RemoteException;
 import android.provider.DocumentsContract;
-import android.test.AndroidTestCase;
 import android.test.suitebuilder.annotation.MediumTest;
 
-import com.android.documentsui.DocumentsProviderHelper;
-import com.android.documentsui.StubProvider;
 import com.android.documentsui.model.DocumentInfo;
-import com.android.documentsui.model.RootInfo;
 
 import java.util.List;
 
 @MediumTest
-public abstract class BaseCopyJobTest extends AndroidTestCase {
-
-    static String AUTHORITY = StubProvider.DEFAULT_AUTHORITY;
-    static final byte[] HAM_BYTES = "ham and cheese".getBytes();
-    static final byte[] FRUITY_BYTES = "I love fruit cakes!".getBytes();
-
-    Context mContext;
-    ContentResolver mResolver;
-    ContentProviderClient mClient;
-    DocumentsProviderHelper mDocs;
-    TestJobListener mJobListener;
-    RootInfo mSrcRoot;
-    RootInfo mDestRoot;
-
-    @Override
-    protected void setUp() throws Exception {
-        super.setUp();
-
-        mJobListener = new TestJobListener();
-
-        // NOTE: Must be the "target" context, else security checks in content provider will fail.
-        mContext = getContext();
-        mResolver = mContext.getContentResolver();
-
-        mClient = mResolver.acquireContentProviderClient(AUTHORITY);
-        mDocs = new DocumentsProviderHelper(AUTHORITY, mClient);
-
-        initTestFiles();
-    }
-
-    @Override
-    protected void tearDown() throws Exception {
-        resetStorage();
-        mClient.release();
-        super.tearDown();
-    }
-
-    private void resetStorage() throws RemoteException {
-        mClient.call("clear", null, null);
-    }
-
-    private void initTestFiles() throws RemoteException {
-        mSrcRoot = mDocs.getRoot(ROOT_0_ID);
-        mDestRoot = mDocs.getRoot(ROOT_1_ID);
-    }
+public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJobTest<T> {
 
     public void runCopyFilesTest() throws Exception {
         Uri testFile1 = mDocs.createDocument(mSrcRoot, "text/plain", "test1.txt");
@@ -174,9 +120,9 @@
 
     public void runNoCopyDirToDescendentTest() throws Exception {
         Uri testDir = mDocs.createFolder(mSrcRoot, "someDir");
-        Uri descDir = mDocs.createFolder(testDir, "theDescendent");
+        Uri destDir = mDocs.createFolder(testDir, "theDescendent");
 
-        createJob(newArrayList(testDir), descDir).run();
+        createJob(newArrayList(testDir), destDir).run();
 
         mJobListener.waitForFinished();
         mJobListener.assertFailed();
@@ -201,10 +147,11 @@
         mDocs.assertChildCount(mDestRoot, 0);
     }
 
-    final CopyJob createJob(List<Uri> srcs) throws Exception {
+    /**
+     * Creates a job with a stack consisting to the default destination.
+     */
+    final T createJob(List<Uri> srcs) throws Exception {
         Uri destination = DocumentsContract.buildDocumentUri(AUTHORITY, mDestRoot.documentId);
         return createJob(srcs, destination);
     }
-
-    abstract CopyJob createJob(List<Uri> srcs, Uri destination) throws Exception;
 }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java
new file mode 100644
index 0000000..691af6a
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.services;
+
+import static com.android.documentsui.StubProvider.ROOT_0_ID;
+import static com.android.documentsui.StubProvider.ROOT_1_ID;
+
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.DocumentsContract;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import com.android.documentsui.DocumentsProviderHelper;
+import com.android.documentsui.StubProvider;
+import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.model.DocumentStack;
+import com.android.documentsui.model.RootInfo;
+
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+@MediumTest
+public abstract class AbstractJobTest<T extends Job> extends AndroidTestCase {
+
+    static String AUTHORITY = StubProvider.DEFAULT_AUTHORITY;
+    static final byte[] HAM_BYTES = "ham and cheese".getBytes();
+    static final byte[] FRUITY_BYTES = "I love fruit cakes!".getBytes();
+
+    Context mContext;
+    ContentResolver mResolver;
+    ContentProviderClient mClient;
+    DocumentsProviderHelper mDocs;
+    TestJobListener mJobListener;
+    RootInfo mSrcRoot;
+    RootInfo mDestRoot;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mJobListener = new TestJobListener();
+
+        // NOTE: Must be the "target" context, else security checks in content provider will fail.
+        mContext = getContext();
+        mResolver = mContext.getContentResolver();
+
+        mClient = mResolver.acquireContentProviderClient(AUTHORITY);
+        mDocs = new DocumentsProviderHelper(AUTHORITY, mClient);
+
+        initTestFiles();
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        resetStorage();
+        mClient.release();
+        super.tearDown();
+    }
+
+    private void resetStorage() throws RemoteException {
+        mClient.call("clear", null, null);
+    }
+
+    private void initTestFiles() throws RemoteException {
+        mSrcRoot = mDocs.getRoot(ROOT_0_ID);
+        mDestRoot = mDocs.getRoot(ROOT_1_ID);
+    }
+
+    final T createJob(List<Uri> srcs, Uri destination) throws Exception {
+        DocumentStack stack = new DocumentStack();
+        stack.push(DocumentInfo.fromUri(mResolver, destination));
+
+        List<DocumentInfo> srcDocs = Lists.newArrayList();
+        for (Uri src : srcs) {
+            srcDocs.add(DocumentInfo.fromUri(mResolver, src));
+        }
+
+        return createJob(srcDocs, stack);
+    }
+
+    abstract T createJob(List<DocumentInfo> srcs, DocumentStack destination) throws Exception;
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java
index c0ce993..1acf2dc 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java
@@ -16,18 +16,15 @@
 
 package com.android.documentsui.services;
 
-import android.net.Uri;
 import android.test.suitebuilder.annotation.MediumTest;
 
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 
-import com.google.common.collect.Lists;
-
 import java.util.List;
 
 @MediumTest
-public class CopyJobTest extends BaseCopyJobTest {
+public class CopyJobTest extends AbstractCopyJobTest<CopyJob> {
 
     public void testCopyFiles() throws Exception {
         runCopyFilesTest();
@@ -62,16 +59,8 @@
     }
 
     @Override
-    CopyJob createJob(List<Uri> srcs, Uri destination) throws Exception {
-        DocumentStack stack = new DocumentStack();
-        stack.push(DocumentInfo.fromUri(mResolver, destination));
-
-        List<DocumentInfo> srcDocs = Lists.newArrayList();
-        for (Uri src : srcs) {
-            srcDocs.add(DocumentInfo.fromUri(mResolver, src));
-        }
-
+    CopyJob createJob(List<DocumentInfo> srcs, DocumentStack stack) throws Exception {
         return new CopyJob(
-                mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcDocs);
+                mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs);
     }
 }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java
new file mode 100644
index 0000000..d6d10239
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.services;
+
+import static com.google.common.collect.Lists.newArrayList;
+
+import android.net.Uri;
+import android.provider.DocumentsContract;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.model.DocumentStack;
+
+import java.util.List;
+
+@MediumTest
+public class DeleteJobTest extends AbstractJobTest<DeleteJob> {
+
+    public void testDeleteFiles() throws Exception {
+        Uri testFile1 = mDocs.createDocument(mSrcRoot, "text/plain", "test1.txt");
+        mDocs.writeDocument(testFile1, HAM_BYTES);
+
+        Uri testFile2 = mDocs.createDocument(mSrcRoot, "text/plain", "test2.txt");
+        mDocs.writeDocument(testFile2, FRUITY_BYTES);
+
+        createJob(newArrayList(testFile1, testFile2)).run();
+        mJobListener.waitForFinished();
+
+        mDocs.assertChildCount(mSrcRoot, 0);
+    }
+
+    /**
+     * Creates a job with a stack consisting to the default src directory.
+     */
+    private final DeleteJob createJob(List<Uri> srcs) throws Exception {
+        Uri stack = DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId);
+        return createJob(srcs, stack);
+    }
+
+    @Override
+    DeleteJob createJob(List<DocumentInfo> srcs, DocumentStack stack) throws Exception {
+        return new DeleteJob(
+                mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs);
+    }
+}
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 d55b6f0..4d5392e 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
@@ -114,8 +114,11 @@
     public void testShutdownStopsExecutor_AfterSuccess() throws Exception {
         startService(createCopyIntent(newArrayList(ALPHA_DOC), BETA_DOC));
 
-        mExecutor.isAlive();
+        mExecutor.assertAlive();
+
         mExecutor.runAll();
+        shutdownService();
+
         mExecutor.assertShutdown();
     }
 
@@ -126,6 +129,8 @@
         mJobFactory.jobs.get(0).fail(ALPHA_DOC);
 
         mExecutor.runAll();
+        shutdownService();
+
         mExecutor.assertShutdown();
     }
 
@@ -137,6 +142,8 @@
         mJobFactory.jobs.get(1).fail(GAMMA_DOC);
 
         mExecutor.runAll();
+        shutdownService();
+
         mExecutor.assertShutdown();
     }
 
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java
index 5e41524..7edfcdb 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java
@@ -16,18 +16,15 @@
 
 package com.android.documentsui.services;
 
-import android.net.Uri;
 import android.test.suitebuilder.annotation.MediumTest;
 
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 
-import com.google.common.collect.Lists;
-
 import java.util.List;
 
 @MediumTest
-public class MoveJobTest extends BaseCopyJobTest {
+public class MoveJobTest extends AbstractCopyJobTest<MoveJob> {
 
     public void testMoveFiles() throws Exception {
         runCopyFilesTest();
@@ -82,16 +79,8 @@
     }
 
     @Override
-    CopyJob createJob(List<Uri> srcs, Uri destination) throws Exception {
-        DocumentStack stack = new DocumentStack();
-        stack.push(DocumentInfo.fromUri(mResolver, destination));
-
-        List<DocumentInfo> srcDocs = Lists.newArrayList();
-        for (Uri src : srcs) {
-            srcDocs.add(DocumentInfo.fromUri(mResolver, src));
-        }
-
+    MoveJob createJob(List<DocumentInfo> srcs, DocumentStack stack) throws Exception {
         return new MoveJob(
-                mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcDocs);
+                mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs);
     }
 }
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 72da9a1..9c58780 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java
@@ -46,9 +46,6 @@
         assertTrue(mStarted);
     }
 
-    @Override
-    void cleanup() {}
-
     void fail(DocumentInfo doc) {
         onFileFailed(doc);
     }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java
index 5c39b78..4d417cf 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java
@@ -146,7 +146,7 @@
         scheduled.get(taskIndex).runnable.run();
     }
 
-    public void isAlive() {
+    public void assertAlive() {
         assertFalse(isShutdown());
     }