Make srcParent for move/delete nullable.

Add 3 unit tests to cover these cases.

Test: It stopped crashing. New tests pass.

Bug: 33540755
Change-Id: I0c80ff1b0aa25cc218b4a0538a11189d36d6826b
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index d15f407..0efb19c 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -736,10 +736,10 @@
             throw new RuntimeException("Failed to create uri supplier.", e);
         }
 
-        Uri srcParent = mState.stack.peek().derivedUri;
+        final DocumentInfo parent = mState.stack.peek();
         mLocalState.mPendingOperation = new FileOperation.Builder()
                 .withOpType(mode)
-                .withSrcParent(srcParent)
+                .withSrcParent(parent == null ? null : parent.derivedUri)
                 .withSrcs(srcs)
                 .build();
 
diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java
index e365c4d..e0583a2 100644
--- a/src/com/android/documentsui/files/ActionHandler.java
+++ b/src/com/android/documentsui/files/ActionHandler.java
@@ -244,8 +244,7 @@
             return;
         }
 
-        final DocumentInfo srcParent = mState.stack.peek();
-        assert(srcParent != null);
+        final @Nullable DocumentInfo srcParent = mState.stack.peek();
 
         // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
         List<DocumentInfo> docs = mScope.model.getDocuments(selection);
@@ -272,7 +271,7 @@
                     .withOpType(FileOperationService.OPERATION_DELETE)
                     .withDestination(mState.stack)
                     .withSrcs(srcs)
-                    .withSrcParent(srcParent.derivedUri)
+                    .withSrcParent(srcParent == null ? null : srcParent.derivedUri)
                     .build();
 
             FileOperations.start(mActivity, operation, mDialogs::showFileOperationStatus);
diff --git a/src/com/android/documentsui/services/DeleteJob.java b/src/com/android/documentsui/services/DeleteJob.java
index e77beae..5d32b70 100644
--- a/src/com/android/documentsui/services/DeleteJob.java
+++ b/src/com/android/documentsui/services/DeleteJob.java
@@ -32,6 +32,7 @@
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
 
+import javax.annotation.Nullable;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -40,9 +41,9 @@
 
     private static final String TAG = "DeleteJob";
 
-    private volatile int mDocsProcessed = 0;
+    private final Uri mSrcParent;
 
-    Uri mSrcParent;
+    private volatile int mDocsProcessed = 0;
     /**
      * Moves files to a destination identified by {@code destination}.
      * Performs most work by delegating to CopyJob, then deleting
@@ -50,8 +51,8 @@
      *
      * @see @link {@link Job} constructor for most param descriptions.
      */
-    DeleteJob(Context service, Listener listener, String id, Uri srcParent, DocumentStack stack,
-            UrisSupplier srcs) {
+    DeleteJob(Context service, Listener listener, String id, DocumentStack stack,
+            UrisSupplier srcs, @Nullable Uri srcParent) {
         super(service, listener, id, OPERATION_DELETE, stack, srcs);
         mSrcParent = srcParent;
     }
@@ -100,7 +101,10 @@
             final Iterable<Uri> uris = this.srcs.getUris(appContext);
 
             final ContentResolver resolver = appContext.getContentResolver();
-            final DocumentInfo srcParent = DocumentInfo.fromUri(resolver, mSrcParent);
+            final DocumentInfo srcParent =
+                mSrcParent != null
+                    ? DocumentInfo.fromUri(resolver, mSrcParent)
+                    : null;
             for (Uri uri : uris) {
                 DocumentInfo doc = DocumentInfo.fromUri(resolver, uri);
                 srcs.add(doc);
diff --git a/src/com/android/documentsui/services/FileOperation.java b/src/com/android/documentsui/services/FileOperation.java
index 5e4c21e..b2e40ea 100644
--- a/src/com/android/documentsui/services/FileOperation.java
+++ b/src/com/android/documentsui/services/FileOperation.java
@@ -31,6 +31,8 @@
 import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.services.FileOperationService.OpType;
 
+import javax.annotation.Nullable;
+
 /**
  * FileOperation describes a file operation, such as move/copy/delete etc.
  */
@@ -136,13 +138,12 @@
     }
 
     public static class MoveDeleteOperation extends FileOperation {
-        private final Uri mSrcParent;
+        private final @Nullable Uri mSrcParent;
 
-        private MoveDeleteOperation(
-                @OpType int opType, UrisSupplier srcs, Uri srcParent, DocumentStack destination) {
+        private MoveDeleteOperation(@OpType int opType, UrisSupplier srcs,
+                DocumentStack destination, @Nullable Uri srcParent) {
             super(opType, srcs, destination);
 
-            assert(srcParent != null);
             mSrcParent = srcParent;
         }
 
@@ -151,10 +152,10 @@
             switch(getOpType()) {
                 case OPERATION_MOVE:
                     return new MoveJob(
-                            service, listener, id, mSrcParent, getDestination(), getSrc());
+                            service, listener, id, getDestination(), getSrc(), mSrcParent);
                 case OPERATION_DELETE:
                     return new DeleteJob(
-                            service, listener, id, mSrcParent, getDestination(), getSrc());
+                            service, listener, id, getDestination(), getSrc(), mSrcParent);
                 default:
                     throw new UnsupportedOperationException("Unsupported op type: " + getOpType());
             }
@@ -210,7 +211,7 @@
             return this;
         }
 
-        public Builder withSrcParent(Uri srcParent) {
+        public Builder withSrcParent(@Nullable Uri srcParent) {
             mSrcParent = srcParent;
             return this;
         }
@@ -231,7 +232,7 @@
                     return new CopyOperation(mSrcs, mDestination);
                 case OPERATION_MOVE:
                 case OPERATION_DELETE:
-                    return new MoveDeleteOperation(mOpType, mSrcs, mSrcParent, mDestination);
+                    return new MoveDeleteOperation(mOpType, mSrcs, mDestination, mSrcParent);
                 default:
                     throw new UnsupportedOperationException("Unsupported op type: " + mOpType);
             }
diff --git a/src/com/android/documentsui/services/Job.java b/src/com/android/documentsui/services/Job.java
index 22f45ff..3cec4a4 100644
--- a/src/com/android/documentsui/services/Job.java
+++ b/src/com/android/documentsui/services/Job.java
@@ -50,6 +50,7 @@
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.services.FileOperationService.OpType;
 
+import javax.annotation.Nullable;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -225,9 +226,10 @@
         return false;
     }
 
-    final void deleteDocument(DocumentInfo doc, DocumentInfo parent) throws ResourceException {
+    final void deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent)
+            throws ResourceException {
         try {
-            if (doc.isRemoveSupported()) {
+            if (parent != null && doc.isRemoveSupported()) {
                 DocumentsContract.removeDocument(getClient(doc), doc.derivedUri, parent.derivedUri);
             } else if (doc.isDeleteSupported()) {
                 DocumentsContract.deleteDocument(getClient(doc), doc.derivedUri);
diff --git a/src/com/android/documentsui/services/MoveJob.java b/src/com/android/documentsui/services/MoveJob.java
index 7e66cb0..a315579 100644
--- a/src/com/android/documentsui/services/MoveJob.java
+++ b/src/com/android/documentsui/services/MoveJob.java
@@ -34,6 +34,7 @@
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.clipping.UrisSupplier;
 
+import javax.annotation.Nullable;
 import java.io.FileNotFoundException;
 
 // TODO: Stop extending CopyJob.
@@ -41,8 +42,10 @@
 
     private static final String TAG = "MoveJob";
 
-    Uri mSrcParentUri;
-    DocumentInfo mSrcParent;
+    private final @Nullable Uri mSrcParentUri;
+
+    // mSrcParent may be populated during setup.
+    private @Nullable DocumentInfo mSrcParent;
 
     /**
      * Moves files to a destination identified by {@code destination}.
@@ -52,7 +55,7 @@
      * @see @link {@link Job} constructor for most param descriptions.
      */
     MoveJob(Context service, Listener listener,
-            String id, Uri srcParent, DocumentStack destination, UrisSupplier srcs) {
+            String id, DocumentStack destination, UrisSupplier srcs, @Nullable Uri srcParent) {
         super(service, listener, id, OPERATION_MOVE, destination, srcs);
         mSrcParentUri = srcParent;
     }
@@ -84,13 +87,15 @@
 
     @Override
     public boolean setUp() {
-        final ContentResolver resolver = appContext.getContentResolver();
-        try {
-            mSrcParent = DocumentInfo.fromUri(resolver, mSrcParentUri);
-        } catch(FileNotFoundException e) {
-            Log.e(TAG, "Failed to create srcParent.", e);
-            failedFileCount += srcs.getItemCount();
-            return false;
+        if (mSrcParentUri != null) {
+            final ContentResolver resolver = appContext.getContentResolver();
+            try {
+                mSrcParent = DocumentInfo.fromUri(resolver, mSrcParentUri);
+            } catch (FileNotFoundException e) {
+                Log.e(TAG, "Failed to create srcParent.", e);
+                failedFileCount += srcs.getItemCount();
+                return false;
+            }
         }
 
         return super.setUp();
@@ -134,7 +139,7 @@
 
         // When moving within the same provider, try to use optimized moving.
         // If not supported, then fallback to byte-by-byte copy/move.
-        if (src.authority.equals(dest.authority)) {
+        if (src.authority.equals(dest.authority) && (srcParent != null || mSrcParent != null)) {
             if ((src.flags & Document.FLAG_SUPPORTS_MOVE) != 0) {
                 try {
                     if (DocumentsContract.moveDocument(getClient(src), src.derivedUri,
diff --git a/tests/common/com/android/documentsui/testing/TestRootsAccess.java b/tests/common/com/android/documentsui/testing/TestRootsAccess.java
index 7a97ffb..45a30c7 100644
--- a/tests/common/com/android/documentsui/testing/TestRootsAccess.java
+++ b/tests/common/com/android/documentsui/testing/TestRootsAccess.java
@@ -15,6 +15,7 @@
  */
 package com.android.documentsui.testing;
 
+import android.provider.DocumentsContract.Root;
 import com.android.documentsui.base.Providers;
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.base.State;
@@ -34,6 +35,7 @@
     public static final RootInfo HOME;
     public static final RootInfo HAMMY;
     public static final RootInfo PICKLES;
+    public static final RootInfo RECENTS;
 
     static {
         DOWNLOADS = new RootInfo();
@@ -51,6 +53,13 @@
         PICKLES = new RootInfo();
         PICKLES.authority = "yummies";
         PICKLES.rootId = "pickles";
+
+        RECENTS = new RootInfo() {{
+            // Special root for recents
+            derivedType = RootInfo.TYPE_RECENTS;
+            flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_IS_CHILD;
+            availableBytes = -1;
+        }};
     }
 
     public final Map<String, Collection<RootInfo>> roots = new HashMap<>();
diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
index 013a391..4470b99 100644
--- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
+++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
@@ -157,6 +157,17 @@
         mActionModeAddons.finishOnConfirmed.assertRejected();
     }
 
+    // Recents root means when deleting the srcParent will be null.
+    @Test
+    public void testDeleteSelectedDocuments_RecentsRoot() {
+        mEnv.state.stack.changeRoot(TestRootsAccess.RECENTS);
+
+        mHandler.deleteSelectedDocuments();
+        mDialogs.assertNoFileFailures();
+        mActivity.startService.assertCalled();
+        mActionModeAddons.finishOnConfirmed.assertCalled();
+    }
+
     @Test
     public void testShareSelectedDocuments_ShowsChooser() {
         mActivity.resources.strings.put(R.string.share_via, "Sharezilla!");
diff --git a/tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java b/tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java
index 6b52fdd..83b2109 100644
--- a/tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java
+++ b/tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java
@@ -167,6 +167,10 @@
      */
     final T createJob(List<Uri> srcs) throws Exception {
         Uri srcParent = DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId);
+        return createJob(srcs, srcParent);
+    }
+
+    final T createJob(List<Uri> srcs, Uri srcParent) throws Exception {
         Uri destination = DocumentsContract.buildDocumentUri(AUTHORITY, mDestRoot.documentId);
         return createJob(mOpType, srcs, srcParent, destination);
     }
diff --git a/tests/unit/com/android/documentsui/services/DeleteJobTest.java b/tests/unit/com/android/documentsui/services/DeleteJobTest.java
index 9dbe7ce..f97a360 100644
--- a/tests/unit/com/android/documentsui/services/DeleteJobTest.java
+++ b/tests/unit/com/android/documentsui/services/DeleteJobTest.java
@@ -43,6 +43,19 @@
         mDocs.assertChildCount(mSrcRoot, 0);
     }
 
+    public void testDeleteFiles_NoSrcParent() 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), null).run();
+        mJobListener.waitForFinished();
+
+        mDocs.assertChildCount(mSrcRoot, 0);
+    }
+
     /**
      * Creates a job with a stack consisting to the default src directory.
      */
diff --git a/tests/unit/com/android/documentsui/services/MoveJobTest.java b/tests/unit/com/android/documentsui/services/MoveJobTest.java
index 56d96cc..6bdd9ed 100644
--- a/tests/unit/com/android/documentsui/services/MoveJobTest.java
+++ b/tests/unit/com/android/documentsui/services/MoveJobTest.java
@@ -37,6 +37,23 @@
         mDocs.assertChildCount(mSrcRoot, 0);
     }
 
+    public void testMoveFiles_NoSrcParent() 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), null).run();
+        mJobListener.waitForFinished();
+
+        mDocs.assertChildCount(mDestRoot, 2);
+        mDocs.assertHasFile(mDestRoot, "test1.txt");
+        mDocs.assertHasFile(mDestRoot, "test2.txt");
+        mDocs.assertFileContents(mDestRoot.documentId, "test1.txt", HAM_BYTES);
+        mDocs.assertFileContents(mDestRoot.documentId, "test2.txt", FRUITY_BYTES);
+    }
+
     public void testMoveVirtualTypedFile() throws Exception {
         mDocs.createFolder(mSrcRoot, "hello");
         Uri testFile = mDocs.createVirtualFile(