Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b1404b77 authored by Ben Lin's avatar Ben Lin
Browse files

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

Bug: 27451823
Change-Id: I062dcbd065434c22a3ffeb33d4cac2b4f9da104b
(cherry picked from commit 5b696f91aa0f12f29be5205647bdc398e2831af8)
parent dfd2ff95
Loading
Loading
Loading
Loading
+95 −36
Original line number Diff line number Diff line
@@ -23,11 +23,14 @@ import android.content.ContentResolver;
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 @@ import java.util.List;
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 @@ public final class DocumentClipper {
    }

    /**
     * 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 @@ public final class DocumentClipper {
     * 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;
        }
    }
}
+6 −0
Original line number Diff line number Diff line
@@ -359,6 +359,12 @@ public class FilesActivity extends BaseActivity {
                    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) {
+3 −1
Original line number Diff line number Diff line
@@ -243,6 +243,7 @@ public final class Metrics {
    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 @@ public final class Metrics {
            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 {}
+113 −55
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ import android.os.AsyncTask;
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.BaseActivity;
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 @@ public class DirectoryFragment extends Fragment
        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 @@ public class DirectoryFragment extends Fragment
    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,60 @@ public class DirectoryFragment extends Fragment
        }

        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;
        }
        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);
        }

        FileOperations.copy(getActivity(), docs, tmpStack);
    }

    public void copySelectedToClipboard() {
        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD);

        Selection selection = mSelectionManager.getSelection(new Selection());
        if (!selection.isEmpty()) {
            copySelectionToClipboard(selection);
        if (selection.isEmpty()) {
            return;
        }

        new GetDocumentsTask() {
            @Override
            void onDocumentsReady(List<DocumentInfo> docs) {
                mClipper.clipDocumentsForCopy(docs);
                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 cutSelectedToClipboard() {
        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_CUT_CLIPBOARD);
        Selection selection = mSelectionManager.getSelection(new Selection());
        if (selection.isEmpty()) {
            return;
        }

    void copySelectionToClipboard(Selection selection) {
        assert(!selection.isEmpty());
        new GetDocumentsTask() {
            @Override
            void onDocumentsReady(List<DocumentInfo> docs) {
                mClipper.clipDocuments(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(
@@ -1084,12 +1132,14 @@ public class DirectoryFragment extends Fragment
                        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 @@ public class DirectoryFragment extends Fragment
                    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,8 +1257,12 @@ public class DirectoryFragment extends Fragment
                    return true;

                case DragEvent.ACTION_DROP:
                    // After a drop event, always stop highlighting the target.
                    setDropTargetHighlight(v, false);
                return handleDropEvent(v, event);
            }
            return false;
        }

        private boolean handleDropEvent(View v, DragEvent event) {

            ClipData clipData = event.getClipData();
            if (clipData == null) {
@@ -1214,6 +1270,9 @@ public class DirectoryFragment extends Fragment
                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.
@@ -1236,8 +1295,6 @@ public class DirectoryFragment extends Fragment
            copyFromClipData(clipData, dst);
            return true;
        }
            return false;
        }

        private DocumentInfo getDestination(View v) {
            String id = getModelId(v);
@@ -1552,7 +1609,8 @@ public class DirectoryFragment extends Fragment
                    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 |
+44 −0
Original line number Diff line number Diff line
@@ -138,6 +138,50 @@ public class FilesActivityUiTest extends ActivityTest<FilesActivity> {
        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();

Loading