Enable Ctrl+X cut operations, along with some code refactor.

Bug: 27451823
Change-Id: I062dcbd065434c22a3ffeb33d4cac2b4f9da104b
(cherry picked from commit 5b696f91aa0f12f29be5205647bdc398e2831af8)
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java b/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java
index 059b5e0..908396c 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java
@@ -23,11 +23,14 @@
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
+import android.os.PersistableBundle;
 import android.provider.DocumentsContract;
 import android.support.annotation.Nullable;
 import android.util.Log;
 
 import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.services.FileOperationService.OpType;
 
 import libcore.io.IoUtils;
 
@@ -42,6 +45,8 @@
 public final class DocumentClipper {
 
     private static final String TAG = "DocumentClipper";
+    private static final String SRC_PARENT_KEY = "srcParent";
+    private static final String OP_TYPE_KEY = "opType";
 
     private Context mContext;
     private ClipboardManager mClipboard;
@@ -73,47 +78,41 @@
     }
 
     /**
-     * Returns a list of Documents as decoded from Clipboard primary clipdata.
-     * This should be run from inside an AsyncTask.
+     * Returns details regarding the documents on the primary clipboard
      */
-    public List<DocumentInfo> getClippedDocuments() {
-        ClipData data = mClipboard.getPrimaryClip();
-        return data == null ? Collections.EMPTY_LIST : getDocumentsFromClipData(data);
+    public ClipDetails getClipDetails() {
+        return getClipDetails(mClipboard.getPrimaryClip());
     }
 
-    /**
-     * Returns a list of Documents as decoded in clipData.
-     * This should be run from inside an AsyncTask.
-     */
-    public List<DocumentInfo> getDocumentsFromClipData(ClipData clipData) {
+    public ClipDetails getClipDetails(@Nullable ClipData clipData) {
+        if (clipData == null) {
+            return null;
+        }
+
+        String srcParent = clipData.getDescription().getExtras().getString(SRC_PARENT_KEY);
+
+        ClipDetails clipDetails = new ClipDetails(
+                clipData.getDescription().getExtras().getInt(OP_TYPE_KEY),
+                getDocumentsFromClipData(clipData),
+                createDocument((srcParent != null) ? Uri.parse(srcParent) : null));
+
+        return clipDetails;
+    }
+
+    private List<DocumentInfo> getDocumentsFromClipData(ClipData clipData) {
         assert(clipData != null);
-        final List<DocumentInfo> srcDocs = new ArrayList<>();
 
         int count = clipData.getItemCount();
         if (count == 0) {
-            return srcDocs;
+            return Collections.EMPTY_LIST;
         }
 
-        ContentResolver resolver = mContext.getContentResolver();
+        final List<DocumentInfo> srcDocs = new ArrayList<>();
+
         for (int i = 0; i < count; ++i) {
             ClipData.Item item = clipData.getItemAt(i);
             Uri itemUri = item.getUri();
-            if (itemUri != null && DocumentsContract.isDocumentUri(mContext, itemUri)) {
-                ContentProviderClient client = null;
-                Cursor cursor = null;
-                try {
-                    client = DocumentsApplication.acquireUnstableProviderOrThrow(
-                            resolver, itemUri.getAuthority());
-                    cursor = client.query(itemUri, null, null, null, null);
-                    cursor.moveToPosition(0);
-                    srcDocs.add(DocumentInfo.fromCursor(cursor, itemUri.getAuthority()));
-                } catch (Exception e) {
-                    Log.e(TAG, e.getMessage());
-                } finally {
-                    IoUtils.closeQuietly(cursor);
-                    ContentProviderClient.releaseQuietly(client);
-                }
-            }
+            srcDocs.add(createDocument(itemUri));
         }
 
         return srcDocs;
@@ -123,26 +122,86 @@
      * Returns ClipData representing the list of docs, or null if docs is empty,
      * or docs cannot be converted.
      */
-    public @Nullable ClipData getClipDataForDocuments(List<DocumentInfo> docs) {
+    public @Nullable ClipData getClipDataForDocuments(List<DocumentInfo> docs, @OpType int opType) {
         final ContentResolver resolver = mContext.getContentResolver();
         ClipData clipData = null;
         for (DocumentInfo doc : docs) {
-            final Uri uri = DocumentsContract.buildDocumentUri(doc.authority, doc.documentId);
+            assert(doc != null);
+            assert(doc.derivedUri != null);
             if (clipData == null) {
                 // TODO: figure out what this string should be.
                 // Currently it is not displayed anywhere in the UI, but this might change.
-                final String label = "";
-                clipData = ClipData.newUri(resolver, label, uri);
+                final String clipLabel = "";
+                clipData = ClipData.newUri(resolver, clipLabel, doc.derivedUri);
+                PersistableBundle bundle = new PersistableBundle();
+                bundle.putInt(OP_TYPE_KEY, opType);
+                clipData.getDescription().setExtras(bundle);
             } else {
                 // TODO: update list of mime types in ClipData.
-                clipData.addItem(new ClipData.Item(uri));
+                clipData.addItem(new ClipData.Item(doc.derivedUri));
             }
         }
         return clipData;
     }
 
-    public void clipDocuments(List<DocumentInfo> docs) {
-        ClipData data = getClipDataForDocuments(docs);
+    /**
+     * Puts {@code ClipData} in a primary clipboard, describing a copy operation
+     */
+    public void clipDocumentsForCopy(List<DocumentInfo> docs) {
+        ClipData data = getClipDataForDocuments(docs, FileOperationService.OPERATION_COPY);
+        assert(data != null);
+
         mClipboard.setPrimaryClip(data);
     }
+
+    /**
+     *  Puts {@Code ClipData} in a primary clipboard, describing a cut operation
+     */
+    public void clipDocumentsForCut(List<DocumentInfo> docs, DocumentInfo srcParent) {
+        assert(docs != null);
+        assert(!docs.isEmpty());
+        assert(srcParent != null);
+        assert(srcParent.derivedUri != null);
+
+        ClipData data = getClipDataForDocuments(docs, FileOperationService.OPERATION_MOVE);
+        assert(data != null);
+
+        PersistableBundle bundle = data.getDescription().getExtras();
+        bundle.putString(SRC_PARENT_KEY, srcParent.derivedUri.toString());
+
+        mClipboard.setPrimaryClip(data);
+    }
+
+    private DocumentInfo createDocument(Uri uri) {
+        DocumentInfo doc = null;
+        if (uri != null && DocumentsContract.isDocumentUri(mContext, uri)) {
+            ContentResolver resolver = mContext.getContentResolver();
+            ContentProviderClient client = null;
+            Cursor cursor = null;
+            try {
+                client = DocumentsApplication.acquireUnstableProviderOrThrow(resolver, uri.getAuthority());
+                cursor = client.query(uri, null, null, null, null);
+                cursor.moveToPosition(0);
+                doc = DocumentInfo.fromCursor(cursor, uri.getAuthority());
+            } catch (Exception e) {
+                Log.e(TAG, e.getMessage());
+            } finally {
+                IoUtils.closeQuietly(cursor);
+                ContentProviderClient.releaseQuietly(client);
+            }
+        }
+        return doc;
+    }
+
+    public static class ClipDetails {
+        public final @OpType int opType;
+        public final List<DocumentInfo> docs;
+        public final @Nullable DocumentInfo parent;
+
+        ClipDetails(@OpType int opType, List<DocumentInfo> docs, @Nullable DocumentInfo parent) {
+            this.opType = opType;
+            this.docs = docs;
+            this.parent = parent;
+        }
+    }
 }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
index 84fc6fe..f067d5f 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
@@ -359,6 +359,12 @@
                     dir.selectAllFiles();
                 }
                 return true;
+            case KeyEvent.KEYCODE_X:
+                dir = getDirectoryFragment();
+                if (dir != null) {
+                    dir.cutSelectedToClipboard();
+                }
+                return true;
             case KeyEvent.KEYCODE_C:
                 dir = getDirectoryFragment();
                 if (dir != null) {
diff --git a/packages/DocumentsUI/src/com/android/documentsui/Metrics.java b/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
index 69a6e1f..79123d0 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
@@ -243,6 +243,7 @@
     public static final int USER_ACTION_COPY_CLIPBOARD = 23;
     public static final int USER_ACTION_DRAG_N_DROP = 24;
     public static final int USER_ACTION_DRAG_N_DROP_MULTI_WINDOW = 25;
+    public static final int USER_ACTION_CUT_CLIPBOARD = 26;
 
     @IntDef(flag = false, value = {
             USER_ACTION_OTHER,
@@ -269,7 +270,8 @@
             USER_ACTION_PASTE_CLIPBOARD,
             USER_ACTION_COPY_CLIPBOARD,
             USER_ACTION_DRAG_N_DROP,
-            USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
+            USER_ACTION_DRAG_N_DROP_MULTI_WINDOW,
+            USER_ACTION_CUT_CLIPBOARD
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface UserAction {}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 297fbc7..fab2494 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -48,6 +48,7 @@
 import android.os.Bundle;
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.os.PersistableBundle;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Document;
 import android.support.annotation.Nullable;
@@ -82,6 +83,7 @@
 import com.android.documentsui.DirectoryLoader;
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.DocumentClipper;
+import com.android.documentsui.DocumentClipper.ClipDetails;
 import com.android.documentsui.DocumentsActivity;
 import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.Events;
@@ -1000,19 +1002,24 @@
         return commonType[0] + "/" + commonType[1];
     }
 
-    private void copyFromClipboard() {
-        new AsyncTask<Void, Void, List<DocumentInfo>>() {
+    private void copyFromClipboard(final DocumentInfo destination) {
+        new AsyncTask<Void, Void, ClipDetails>() {
 
             @Override
-            protected List<DocumentInfo> doInBackground(Void... params) {
-                return mClipper.getClippedDocuments();
+            protected ClipDetails doInBackground(Void... params) {
+                return mClipper.getClipDetails();
             }
 
             @Override
-            protected void onPostExecute(List<DocumentInfo> docs) {
-                DocumentInfo destination =
-                        ((BaseActivity) getActivity()).getCurrentDirectory();
-                copyDocuments(docs, destination);
+            protected void onPostExecute(ClipDetails clipDetails) {
+                if (clipDetails == null) {
+                    Log.w(TAG, "Received null clipDetails from primary clipboard. Ignoring.");
+                    return;
+                }
+                List<DocumentInfo> docs = clipDetails.docs;
+                @OpType int type = clipDetails.opType;
+                DocumentInfo srcParent = clipDetails.parent;
+                moveDocuments(docs, destination, type, srcParent);
             }
         }.execute();
     }
@@ -1020,21 +1027,35 @@
     private void copyFromClipData(final ClipData clipData, final DocumentInfo destination) {
         assert(clipData != null);
 
-        new AsyncTask<Void, Void, List<DocumentInfo>>() {
+        new AsyncTask<Void, Void, ClipDetails>() {
 
             @Override
-            protected List<DocumentInfo> doInBackground(Void... params) {
-                return mClipper.getDocumentsFromClipData(clipData);
+            protected ClipDetails doInBackground(Void... params) {
+                return mClipper.getClipDetails(clipData);
             }
 
             @Override
-            protected void onPostExecute(List<DocumentInfo> docs) {
-                copyDocuments(docs, destination);
+            protected void onPostExecute(ClipDetails clipDetails) {
+                if (clipDetails == null) {
+                    Log.w(TAG,  "Received null clipDetails. Ignoring.");
+                    return;
+                }
+
+                List<DocumentInfo> docs = clipDetails.docs;
+                @OpType int type = clipDetails.opType;
+                DocumentInfo srcParent = clipDetails.parent;
+                moveDocuments(docs, destination, type, srcParent);
             }
         }.execute();
     }
 
-    private void copyDocuments(final List<DocumentInfo> docs, final DocumentInfo destination) {
+    /**
+     * Moves {@code docs} from {@code srcParent} to {@code destination}.
+     * operationType can be copy or cut
+     * srcParent Must be non-null for move operations.
+     */
+    private void moveDocuments(final List<DocumentInfo> docs, final DocumentInfo destination,
+            final @OpType int operationType, final DocumentInfo srcParent) {
         BaseActivity activity = (BaseActivity) getActivity();
         if (!canCopy(docs, activity.getCurrentRoot(), destination)) {
             Snackbars.makeSnackbar(
@@ -1050,33 +1071,37 @@
         }
 
         final DocumentStack curStack = getDisplayState().stack;
-        DocumentStack tmpStack = new DocumentStack();
+        DocumentStack dstStack = new DocumentStack();
         if (destination != null) {
-            tmpStack.push(destination);
-            tmpStack.addAll(curStack);
+            dstStack.push(destination);
+            dstStack.addAll(curStack);
         } else {
-            tmpStack = curStack;
+            dstStack = curStack;
         }
-
-        FileOperations.copy(getActivity(), docs, tmpStack);
+        switch (operationType) {
+            case FileOperationService.OPERATION_MOVE:
+                FileOperations.move(getActivity(), docs, srcParent, dstStack);
+                break;
+            case FileOperationService.OPERATION_COPY:
+                FileOperations.copy(getActivity(), docs, dstStack);
+                break;
+            default:
+                throw new UnsupportedOperationException("Unsupported operation: " + operationType);
+        }
     }
 
     public void copySelectedToClipboard() {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD);
 
         Selection selection = mSelectionManager.getSelection(new Selection());
-        if (!selection.isEmpty()) {
-            copySelectionToClipboard(selection);
-            mSelectionManager.clearSelection();
+        if (selection.isEmpty()) {
+            return;
         }
-    }
 
-    void copySelectionToClipboard(Selection selection) {
-        assert(!selection.isEmpty());
         new GetDocumentsTask() {
             @Override
             void onDocumentsReady(List<DocumentInfo> docs) {
-                mClipper.clipDocuments(docs);
+                mClipper.clipDocumentsForCopy(docs);
                 Activity activity = getActivity();
                 Snackbars.makeSnackbar(activity,
                         activity.getResources().getQuantityString(
@@ -1084,12 +1109,37 @@
                         Snackbar.LENGTH_SHORT).show();
             }
         }.execute(selection);
+        mSelectionManager.clearSelection();
+    }
+
+    public void cutSelectedToClipboard() {
+        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_CUT_CLIPBOARD);
+        Selection selection = mSelectionManager.getSelection(new Selection());
+        if (selection.isEmpty()) {
+            return;
+        }
+
+        new GetDocumentsTask() {
+            @Override
+            void onDocumentsReady(List<DocumentInfo> docs) {
+                // We need the srcParent for move operations because we do a copy / delete
+                DocumentInfo currentDoc = getDisplayState().stack.peek();
+                mClipper.clipDocumentsForCut(docs, currentDoc);
+                Activity activity = getActivity();
+                Snackbars.makeSnackbar(activity,
+                        activity.getResources().getQuantityString(
+                                R.plurals.clipboard_files_clipped, docs.size(), docs.size()),
+                        Snackbar.LENGTH_SHORT).show();
+            }
+        }.execute(selection);
+        mSelectionManager.clearSelection();
     }
 
     public void pasteFromClipboard() {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD);
 
-        copyFromClipboard();
+        DocumentInfo destination = ((BaseActivity) getActivity()).getCurrentDirectory();
+        copyFromClipboard(destination);
         getActivity().invalidateOptionsMenu();
     }
 
@@ -1198,6 +1248,8 @@
                     return true;
 
                 case DragEvent.ACTION_DRAG_ENDED:
+                    // After a drop event, always stop highlighting the target.
+                    setDropTargetHighlight(v, false);
                     if (event.getResult()) {
                         // Exit selection mode if the drop was handled.
                         mSelectionManager.clearSelection();
@@ -1205,40 +1257,45 @@
                     return true;
 
                 case DragEvent.ACTION_DROP:
-                    // After a drop event, always stop highlighting the target.
-                    setDropTargetHighlight(v, false);
-
-                    ClipData clipData = event.getClipData();
-                    if (clipData == null) {
-                        Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring.");
-                        return false;
-                    }
-
-                    // Don't copy from the cwd into the cwd. Note: this currently doesn't work for
-                    // multi-window drag, because localState isn't carried over from one process to
-                    // another.
-                    Object src = event.getLocalState();
-                    DocumentInfo dst = getDestination(v);
-                    if (Objects.equals(src, dst)) {
-                        if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring.");
-                        return false;
-                    }
-
-                    // Recognize multi-window drag and drop based on the fact that localState is not
-                    // carried between processes. It will stop working when the localsState behavior
-                    // is changed. The info about window should be passed in the localState then.
-                    // The localState could also be null for copying from Recents in single window
-                    // mode, but Recents doesn't offer this functionality (no directories).
-                    Metrics.logUserAction(getContext(),
-                            src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
-                                    : Metrics.USER_ACTION_DRAG_N_DROP);
-
-                    copyFromClipData(clipData, dst);
-                    return true;
+                return handleDropEvent(v, event);
             }
             return false;
         }
 
+        private boolean handleDropEvent(View v, DragEvent event) {
+
+            ClipData clipData = event.getClipData();
+            if (clipData == null) {
+                Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring.");
+                return false;
+            }
+
+            ClipDetails clipDetails = mClipper.getClipDetails(clipData);
+            assert(clipDetails.opType == FileOperationService.OPERATION_COPY);
+
+            // Don't copy from the cwd into the cwd. Note: this currently doesn't work for
+            // multi-window drag, because localState isn't carried over from one process to
+            // another.
+            Object src = event.getLocalState();
+            DocumentInfo dst = getDestination(v);
+            if (Objects.equals(src, dst)) {
+                if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring.");
+                return false;
+            }
+
+            // Recognize multi-window drag and drop based on the fact that localState is not
+            // carried between processes. It will stop working when the localsState behavior
+            // is changed. The info about window should be passed in the localState then.
+            // The localState could also be null for copying from Recents in single window
+            // mode, but Recents doesn't offer this functionality (no directories).
+            Metrics.logUserAction(getContext(),
+                    src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
+                            : Metrics.USER_ACTION_DRAG_N_DROP);
+
+            copyFromClipData(clipData, dst);
+            return true;
+        }
+
         private DocumentInfo getDestination(View v) {
             String id = getModelId(v);
             if (id != null) {
@@ -1552,7 +1609,8 @@
                     return false;
                 }
                 v.startDragAndDrop(
-                        mClipper.getClipDataForDocuments(docs),
+                        mClipper.getClipDataForDocuments(docs,
+                                FileOperationService.OPERATION_COPY),
                         new DragShadowBuilder(getActivity(), mIconHelper, docs),
                         getDisplayState().stack.peek(),
                         View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ |
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java
index 69f0e67..c9b9ff2 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java
@@ -138,6 +138,50 @@
         bots.directory.assertDocumentsAbsent("file1.png");
     }
 
+    public void testKeyboard_CutDocument() throws Exception {
+        initTestFiles();
+
+        bots.roots.openRoot(ROOT_0_ID);
+
+        bots.directory.clickDocument("file1.png");
+        device.waitForIdle();
+        bots.main.pressKey(KeyEvent.KEYCODE_X, KeyEvent.META_CTRL_ON);
+
+        device.waitForIdle();
+
+        bots.roots.openRoot(ROOT_1_ID);
+        bots.main.pressKey(KeyEvent.KEYCODE_V, KeyEvent.META_CTRL_ON);
+
+        device.waitForIdle();
+
+        bots.directory.assertDocumentsPresent("file1.png");
+
+        bots.roots.openRoot(ROOT_0_ID);
+        bots.directory.assertDocumentsAbsent("file1.png");
+    }
+
+    public void testKeyboard_CopyDocument() throws Exception {
+        initTestFiles();
+
+        bots.roots.openRoot(ROOT_0_ID);
+
+        bots.directory.clickDocument("file1.png");
+        device.waitForIdle();
+        bots.main.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON);
+
+        device.waitForIdle();
+
+        bots.roots.openRoot(ROOT_1_ID);
+        bots.main.pressKey(KeyEvent.KEYCODE_V, KeyEvent.META_CTRL_ON);
+
+        device.waitForIdle();
+
+        bots.directory.assertDocumentsPresent("file1.png");
+
+        bots.roots.openRoot(ROOT_0_ID);
+        bots.directory.assertDocumentsPresent("file1.png");
+    }
+
     public void testDeleteDocument_Cancel() throws Exception {
         initTestFiles();
 
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/bots/UiBot.java b/packages/DocumentsUI/tests/src/com/android/documentsui/bots/UiBot.java
index 4c8dc00..b099d10 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/bots/UiBot.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/bots/UiBot.java
@@ -224,4 +224,8 @@
     public void pressKey(int keyCode) {
         mDevice.pressKeyCode(keyCode);
     }
+
+    public void pressKey(int keyCode, int metaState) {
+        mDevice.pressKeyCode(keyCode, metaState);
+    }
 }