From 30b0dc1896abc67a970b61ebfd420275a31c1e18 Mon Sep 17 00:00:00 2001 From: Ben Lin Date: Tue, 7 Mar 2017 15:37:16 -0800 Subject: Fix a bug where dropping on Root never works. DragEvent gets recycled, so by passing a DragEvent reference directly to ActionHandler#dropOn, by the time the callback occurs, DragEvent is updated to ACTION_DRAG_ENDED. ACTION_DRAG_ENDED events have no ClipData and no localState, so the file operation never will occur. Also added tests that involved ... refactoring lots of things. Change-Id: I87daf1a4ec4e536701e03fd6dc53fc55829e5e51 --- .../android/documentsui/AbstractActionHandler.java | 14 + src/com/android/documentsui/ActionHandler.java | 10 + src/com/android/documentsui/BaseActivity.java | 7 +- .../android/documentsui/DocumentsApplication.java | 2 +- src/com/android/documentsui/RefreshTask.java | 3 +- src/com/android/documentsui/TimeoutTask.java | 13 +- src/com/android/documentsui/base/RootInfo.java | 14 - .../documentsui/clipping/DocumentClipper.java | 293 ++---------------- .../clipping/RuntimeDocumentClipper.java | 336 +++++++++++++++++++++ .../documentsui/dirlist/DirectoryFragment.java | 3 +- .../android/documentsui/files/ActionHandler.java | 34 ++- .../android/documentsui/files/FilesActivity.java | 9 +- .../documentsui/roots/GetRootDocumentTask.java | 19 +- .../android/documentsui/sidebar/RootsFragment.java | 7 +- .../documentsui/testing/TestDocumentClipper.java | 79 +++++ .../documentsui/files/ActionHandlerTest.java | 92 +++++- 16 files changed, 592 insertions(+), 343 deletions(-) create mode 100644 src/com/android/documentsui/clipping/RuntimeDocumentClipper.java create mode 100644 tests/common/com/android/documentsui/testing/TestDocumentClipper.java diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java index d4e964c2b..617e5aef3 100644 --- a/src/com/android/documentsui/AbstractActionHandler.java +++ b/src/com/android/documentsui/AbstractActionHandler.java @@ -51,6 +51,7 @@ import com.android.documentsui.dirlist.DocumentDetails; import com.android.documentsui.dirlist.FocusHandler; import com.android.documentsui.files.LauncherActivity; import com.android.documentsui.queries.SearchViewManager; +import com.android.documentsui.roots.GetRootDocumentTask; import com.android.documentsui.roots.LoadRootTask; import com.android.documentsui.roots.RootsAccess; import com.android.documentsui.selection.Selection; @@ -61,6 +62,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; +import java.util.function.Consumer; import javax.annotation.Nullable; @@ -141,6 +143,18 @@ public abstract class AbstractActionHandler listener).executeOnExecutor(ProviderExecutor.forAuthority(root.authority)); } + @Override + public void getRootDocument(RootInfo root, int timeout, Consumer callback) { + GetRootDocumentTask task = new GetRootDocumentTask( + root, + mActivity, + timeout, + mDocs, + callback); + + task.executeOnExecutor(mExecutors.lookup(root.authority)); + } + @Override public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) { RefreshTask task = new RefreshTask( diff --git a/src/com/android/documentsui/ActionHandler.java b/src/com/android/documentsui/ActionHandler.java index 33f1d7dcb..96187cc85 100644 --- a/src/com/android/documentsui/ActionHandler.java +++ b/src/com/android/documentsui/ActionHandler.java @@ -28,6 +28,8 @@ import com.android.documentsui.base.DocumentStack; import com.android.documentsui.base.RootInfo; import com.android.documentsui.dirlist.DocumentDetails; +import java.util.function.Consumer; + import javax.annotation.Nullable; public interface ActionHandler { @@ -44,6 +46,14 @@ public interface ActionHandler { */ void ejectRoot(RootInfo root, BooleanConsumer listener); + + /** + * Attempts to fetch the DocumentInfo for the supplied root. Returns the DocumentInfo to the + * callback. If the task times out, callback will be called with null DocumentInfo. Supply + * {@link TimeoutTask#DEFAULT_TIMEOUT} if you don't want to the task to ever time out. + */ + void getRootDocument(RootInfo root, int timeout, Consumer callback); + /** * Attempts to refresh the given DocumentInfo, which should be at the top of the state stack. * Returns a boolean answer to the callback, given by {@link ContentProvider#refresh}. diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java index 1bb5bb88f..3bccfc4e7 100644 --- a/src/com/android/documentsui/BaseActivity.java +++ b/src/com/android/documentsui/BaseActivity.java @@ -309,11 +309,10 @@ public abstract class BaseActivity if (mRoots.isRecentsRoot(root)) { refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); } else { - new GetRootDocumentTask( + mInjector.actions.getRootDocument( root, - this, - mInjector.actions::openRootDocument) - .executeOnExecutor(getExecutorForCurrentDirectory()); + TimeoutTask.DEFAULT_TIMEOUT, + mInjector.actions::openRootDocument); } } diff --git a/src/com/android/documentsui/DocumentsApplication.java b/src/com/android/documentsui/DocumentsApplication.java index 94e9da608..512baba59 100644 --- a/src/com/android/documentsui/DocumentsApplication.java +++ b/src/com/android/documentsui/DocumentsApplication.java @@ -84,7 +84,7 @@ public class DocumentsApplication extends Application { mClipStore = new ClipStorage( ClipStorage.prepareStorage(getCacheDir()), getSharedPreferences(ClipStorage.PREF_NAME, 0)); - mClipper = new DocumentClipper(this, mClipStore); + mClipper = DocumentClipper.create(this, mClipStore); final IntentFilter packageFilter = new IntentFilter(); packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); diff --git a/src/com/android/documentsui/RefreshTask.java b/src/com/android/documentsui/RefreshTask.java index deaf0ca6a..fc9bc4d54 100644 --- a/src/com/android/documentsui/RefreshTask.java +++ b/src/com/android/documentsui/RefreshTask.java @@ -52,14 +52,13 @@ public class RefreshTask extends TimeoutTask { public RefreshTask(Features features, State state, DocumentInfo doc, long timeout, @ApplicationScope Context context, Check check, BooleanConsumer callback) { - super(check); + super(check, timeout); mFeatures = features; mState = state; mDoc = doc; mContext = context; mCallback = callback; mSignal = new CancellationSignal(); - setTimeout(timeout); } @Override diff --git a/src/com/android/documentsui/TimeoutTask.java b/src/com/android/documentsui/TimeoutTask.java index 57b119e8f..9e7bbb810 100644 --- a/src/com/android/documentsui/TimeoutTask.java +++ b/src/com/android/documentsui/TimeoutTask.java @@ -22,23 +22,18 @@ import android.os.Handler; import android.os.Looper; import com.android.documentsui.base.CheckedTask; -import com.android.documentsui.base.DocumentInfo; /** - * A {@link CheckedTask} that takes and query SAF to obtain the - * {@link DocumentInfo} of its root document and call supplied callback to handle the - * {@link DocumentInfo}. + * A {@link CheckedTask} that will timeout after a certain period of time, and do any properly clean + * up necessary before ending itself. */ public abstract class TimeoutTask extends CheckedTask { - private static final int DEFAULT_TIMEOUT = -1; + public static final int DEFAULT_TIMEOUT = -1; private long mTimeout = DEFAULT_TIMEOUT; - public TimeoutTask(Check check) { + public TimeoutTask(Check check, long timeout) { super(check); - } - - public void setTimeout(long timeout) { mTimeout = timeout; } diff --git a/src/com/android/documentsui/base/RootInfo.java b/src/com/android/documentsui/base/RootInfo.java index 64677e596..3fe9a2188 100644 --- a/src/com/android/documentsui/base/RootInfo.java +++ b/src/com/android/documentsui/base/RootInfo.java @@ -349,20 +349,6 @@ public class RootInfo implements Durable, Parcelable, Comparable { return IconUtils.applyTintColor(context, R.drawable.ic_eject, R.color.item_eject_icon); } - /** - * @deprecate use {@link DocumentsAccess#getRootDocument}. - */ - @Deprecated - public @Nullable DocumentInfo getRootDocumentBlocking(Context context) { - try { - final Uri uri = DocumentsContract.buildDocumentUri(authority, documentId); - return DocumentInfo.fromUri(context.getContentResolver(), uri); - } catch (FileNotFoundException e) { - Log.w(TAG, "Failed to find root", e); - return null; - } - } - @Override public boolean equals(Object o) { if (o == null) { diff --git a/src/com/android/documentsui/clipping/DocumentClipper.java b/src/com/android/documentsui/clipping/DocumentClipper.java index 0c1b59c14..b8171bdf5 100644 --- a/src/com/android/documentsui/clipping/DocumentClipper.java +++ b/src/com/android/documentsui/clipping/DocumentClipper.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 The Android Open Source Project + * Copyright (C) 2017 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. @@ -17,203 +17,49 @@ package com.android.documentsui.clipping; import android.content.ClipData; -import android.content.ClipDescription; import android.content.ClipboardManager; -import android.content.ContentResolver; import android.content.Context; 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.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; -import com.android.documentsui.base.Features; import com.android.documentsui.base.RootInfo; -import com.android.documentsui.base.Shared; import com.android.documentsui.selection.Selection; -import com.android.documentsui.services.FileOperation; -import com.android.documentsui.services.FileOperationService; import com.android.documentsui.services.FileOperationService.OpType; import com.android.documentsui.services.FileOperations; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; import java.util.function.Function; -/** - * ClipboardManager wrapper class providing higher level logical - * support for dealing with Documents. - */ -public final class DocumentClipper { - - private static final String TAG = "DocumentClipper"; +public interface DocumentClipper { - static final String SRC_PARENT_KEY = "srcParent"; - static final String OP_TYPE_KEY = "opType"; static final String OP_JUMBO_SELECTION_SIZE = "jumboSelection-size"; static final String OP_JUMBO_SELECTION_TAG = "jumboSelection-tag"; - private final Context mContext; - private final ClipStore mClipStore; - private final ClipboardManager mClipboard; - - public DocumentClipper(Context context, ClipStore clipStore) { - mContext = context; - mClipStore = clipStore; - mClipboard = context.getSystemService(ClipboardManager.class); - } - - public boolean hasItemsToPaste() { - if (mClipboard.hasPrimaryClip()) { - ClipData clipData = mClipboard.getPrimaryClip(); - - int count = clipData.getItemCount(); - if (count > 0) { - for (int i = 0; i < count; ++i) { - ClipData.Item item = clipData.getItemAt(i); - Uri uri = item.getUri(); - if (isDocumentUri(uri)) { - return true; - } - } - } - } - return false; + static public DocumentClipper create(Context context, ClipStore clipStore) { + return new RuntimeDocumentClipper(context, clipStore); } - private boolean isDocumentUri(@Nullable Uri uri) { - return uri != null && DocumentsContract.isDocumentUri(mContext, uri); - } + boolean hasItemsToPaste(); + @OpType int getOpType(ClipData data); /** * Returns {@link ClipData} representing the selection, or null if selection is empty, * or cannot be converted. */ - public ClipData getClipDataForDocuments( - Function uriBuilder, Selection selection, @OpType int opType) { - - assert(selection != null); - - if (selection.isEmpty()) { - Log.w(TAG, "Attempting to clip empty selection. Ignoring."); - return null; - } - - return (selection.size() > Shared.MAX_DOCS_IN_INTENT) - ? createJumboClipData(uriBuilder, selection, opType) - : createStandardClipData(uriBuilder, selection, opType); - } - - /** - * Returns ClipData representing the selection. - */ - private ClipData createStandardClipData( - Function uriBuilder, Selection selection, @OpType int opType) { - - assert(!selection.isEmpty()); - assert(selection.size() <= Shared.MAX_DOCS_IN_INTENT); - - final ContentResolver resolver = mContext.getContentResolver(); - final ArrayList clipItems = new ArrayList<>(); - final Set clipTypes = new HashSet<>(); - - PersistableBundle bundle = new PersistableBundle(); - bundle.putInt(OP_TYPE_KEY, opType); - - for (String id : selection) { - assert(id != null); - Uri uri = uriBuilder.apply(id); - DocumentInfo.addMimeTypes(resolver, uri, clipTypes); - clipItems.add(new ClipData.Item(uri)); - } - - ClipDescription description = new ClipDescription( - "", // Currently "label" is not displayed anywhere in the UI. - clipTypes.toArray(new String[0])); - description.setExtras(bundle); - - return createClipData(description, clipItems); - } - - /** - * Returns ClipData representing the list of docs - */ - private ClipData createJumboClipData( - Function uriBuilder, Selection selection, @OpType int opType) { - - assert(!selection.isEmpty()); - assert(selection.size() > Shared.MAX_DOCS_IN_INTENT); - - final List uris = new ArrayList<>(selection.size()); - - final int capacity = Math.min(selection.size(), Shared.MAX_DOCS_IN_INTENT); - final ArrayList clipItems = new ArrayList<>(capacity); - - // Set up mime types for the first Shared.MAX_DOCS_IN_INTENT - final ContentResolver resolver = mContext.getContentResolver(); - final Set clipTypes = new HashSet<>(); - int docCount = 0; - for (String id : selection) { - assert(id != null); - Uri uri = uriBuilder.apply(id); - if (docCount++ < Shared.MAX_DOCS_IN_INTENT) { - DocumentInfo.addMimeTypes(resolver, uri, clipTypes); - clipItems.add(new ClipData.Item(uri)); - } - - uris.add(uri); - } - - // Prepare metadata - PersistableBundle bundle = new PersistableBundle(); - bundle.putInt(OP_TYPE_KEY, opType); - bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size()); - - // Persists clip items and gets the slot they were saved under. - int tag = mClipStore.persistUris(uris); - bundle.putInt(OP_JUMBO_SELECTION_TAG, tag); - - ClipDescription description = new ClipDescription( - "", // Currently "label" is not displayed anywhere in the UI. - clipTypes.toArray(new String[0])); - description.setExtras(bundle); - - return createClipData(description, clipItems); - } + ClipData getClipDataForDocuments( + Function uriBuilder, Selection selection, @OpType int opType); /** * Puts {@code ClipData} in a primary clipboard, describing a copy operation */ - public void clipDocumentsForCopy(Function uriBuilder, Selection selection) { - ClipData data = - getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY); - assert(data != null); - - mClipboard.setPrimaryClip(data); - } + void clipDocumentsForCopy(Function uriBuilder, Selection selection); /** * Puts {@Code ClipData} in a primary clipboard, describing a cut operation */ - public void clipDocumentsForCut( - Function uriBuilder, Selection selection, DocumentInfo parent) { - assert(!selection.isEmpty()); - assert(parent.derivedUri != null); - - ClipData data = getClipDataForDocuments(uriBuilder, selection, - FileOperationService.OPERATION_MOVE); - assert(data != null); - - PersistableBundle bundle = data.getDescription().getExtras(); - bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString()); - - mClipboard.setPrimaryClip(data); - } + void clipDocumentsForCut( + Function uriBuilder, Selection selection, DocumentInfo parent); /** * Copies documents from clipboard. It's the same as {@link #copyFromClipData} with clipData @@ -223,13 +69,10 @@ public final class DocumentClipper { * @param docStack the document stack to the destination folder, * @param callback callback to notify when operation finishes. */ - public void copyFromClipboard( + void copyFromClipboard( DocumentInfo destination, DocumentStack docStack, - FileOperations.Callback callback) { - - copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback); - } + FileOperations.Callback callback); /** * Copies documents from clipboard. It's the same as {@link #copyFromClipData} with clipData @@ -238,12 +81,9 @@ public final class DocumentClipper { * @param docStack the document stack to the destination folder, * @param callback callback to notify when operation finishes. */ - public void copyFromClipboard( + void copyFromClipboard( DocumentStack docStack, - FileOperations.Callback callback) { - - copyFromClipData(docStack, mClipboard.getPrimaryClip(), callback); - } + FileOperations.Callback callback); /** * Copied documents from given clip data to a root directory. @@ -252,14 +92,11 @@ public final class DocumentClipper { * @param clipData the clipData to copy from * @param callback callback to notify when operation finishes */ - public void copyFromClipData( + void copyFromClipData( final RootInfo root, final @Nullable DocumentInfo destination, final @Nullable ClipData clipData, - final FileOperations.Callback callback) { - DocumentStack dstStack = new DocumentStack(root, destination); - copyFromClipData(dstStack, clipData, callback); - } + final FileOperations.Callback callback); /** * Copies documents from given clip data to a folder. @@ -270,101 +107,9 @@ public final class DocumentClipper { * @param clipData the clipData to copy from * @param callback callback to notify when operation finishes */ - public void copyFromClipData( + void copyFromClipData( final @Nullable DocumentInfo destination, final DocumentStack docStack, final @Nullable ClipData clipData, - final FileOperations.Callback callback) { - - DocumentStack dstStack = new DocumentStack(docStack, destination); - copyFromClipData(dstStack, clipData, callback); - } - - /** - * Copies documents from given clip data to a folder. - * - * @param dstStack the document stack to the destination folder, including the destination - * folder. - * @param clipData the clipData to copy from - * @param callback callback to notify when operation finishes - */ - private void copyFromClipData( - final DocumentStack dstStack, - final @Nullable ClipData clipData, - final FileOperations.Callback callback) { - - if (clipData == null) { - Log.i(TAG, "Received null clipData. Ignoring."); - return; - } - - PersistableBundle bundle = clipData.getDescription().getExtras(); - @OpType int opType = getOpType(bundle); - try { - if (!canCopy(dstStack.peek())) { - callback.onOperationResult( - FileOperations.Callback.STATUS_REJECTED, getOpType(clipData), 0); - return; - } - - UrisSupplier uris = UrisSupplier.create(clipData, mClipStore); - if (uris.getItemCount() == 0) { - callback.onOperationResult( - FileOperations.Callback.STATUS_ACCEPTED, opType, 0); - return; - } - - String srcParentString = bundle.getString(SRC_PARENT_KEY); - Uri srcParent = srcParentString == null ? null : Uri.parse(srcParentString); - - FileOperation operation = new FileOperation.Builder() - .withOpType(opType) - .withSrcParent(srcParent) - .withDestination(dstStack) - .withSrcs(uris) - .build(); - - FileOperations.start(mContext, operation, callback); - } catch(IOException e) { - Log.e(TAG, "Cannot create uris supplier.", e); - callback.onOperationResult(FileOperations.Callback.STATUS_REJECTED, opType, 0); - return; - } - } - - /** - * Returns true if the list of files can be copied to destination. Note that this - * is a policy check only. Currently the method does not attempt to verify - * available space or any other environmental aspects possibly resulting in - * failure to copy. - * - * @return true if the list of files can be copied to destination. - */ - private static boolean canCopy(@Nullable DocumentInfo dest) { - return dest != null && dest.isDirectory() && dest.isCreateSupported(); - } - - public static @OpType int getOpType(ClipData data) { - PersistableBundle bundle = data.getDescription().getExtras(); - return getOpType(bundle); - } - - private static @OpType int getOpType(PersistableBundle bundle) { - return bundle.getInt(OP_TYPE_KEY); - } - - private static ClipData createClipData( - ClipDescription description, ArrayList clipItems) { - - // technically we want to check >= O, but we'd need to patch back the O version code :| - if (Features.OMC_RUNTIME) { - return new ClipData(description, clipItems); - } - - ClipData clip = new ClipData(description, clipItems.get(0)); - for (int i = 1; i < clipItems.size(); i++) { - clip.addItem(clipItems.get(i)); - } - return clip; - } + final FileOperations.Callback callback); } diff --git a/src/com/android/documentsui/clipping/RuntimeDocumentClipper.java b/src/com/android/documentsui/clipping/RuntimeDocumentClipper.java new file mode 100644 index 000000000..36233e137 --- /dev/null +++ b/src/com/android/documentsui/clipping/RuntimeDocumentClipper.java @@ -0,0 +1,336 @@ +/* + * 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.clipping; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.ContentResolver; +import android.content.Context; +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.base.DocumentInfo; +import com.android.documentsui.base.DocumentStack; +import com.android.documentsui.base.Features; +import com.android.documentsui.base.RootInfo; +import com.android.documentsui.base.Shared; +import com.android.documentsui.selection.Selection; +import com.android.documentsui.services.FileOperation; +import com.android.documentsui.services.FileOperationService; +import com.android.documentsui.services.FileOperationService.OpType; +import com.android.documentsui.services.FileOperations; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +/** + * ClipboardManager wrapper class providing higher level logical + * support for dealing with Documents. + */ +final class RuntimeDocumentClipper implements DocumentClipper { + + private static final String TAG = "DocumentClipper"; + private static final String SRC_PARENT_KEY = "srcParent"; + private static final String OP_TYPE_KEY = "opType"; + + private final Context mContext; + private final ClipStore mClipStore; + private final ClipboardManager mClipboard; + + RuntimeDocumentClipper(Context context, ClipStore clipStore) { + mContext = context; + mClipStore = clipStore; + mClipboard = context.getSystemService(ClipboardManager.class); + } + + @Override + public boolean hasItemsToPaste() { + if (mClipboard.hasPrimaryClip()) { + ClipData clipData = mClipboard.getPrimaryClip(); + + int count = clipData.getItemCount(); + if (count > 0) { + for (int i = 0; i < count; ++i) { + ClipData.Item item = clipData.getItemAt(i); + Uri uri = item.getUri(); + if (isDocumentUri(uri)) { + return true; + } + } + } + } + return false; + } + + private boolean isDocumentUri(@Nullable Uri uri) { + return uri != null && DocumentsContract.isDocumentUri(mContext, uri); + } + + @Override + public ClipData getClipDataForDocuments( + Function uriBuilder, Selection selection, @OpType int opType) { + + assert(selection != null); + + if (selection.isEmpty()) { + Log.w(TAG, "Attempting to clip empty selection. Ignoring."); + return null; + } + + return (selection.size() > Shared.MAX_DOCS_IN_INTENT) + ? createJumboClipData(uriBuilder, selection, opType) + : createStandardClipData(uriBuilder, selection, opType); + } + + /** + * Returns ClipData representing the selection. + */ + private ClipData createStandardClipData( + Function uriBuilder, Selection selection, @OpType int opType) { + + assert(!selection.isEmpty()); + assert(selection.size() <= Shared.MAX_DOCS_IN_INTENT); + + final ContentResolver resolver = mContext.getContentResolver(); + final ArrayList clipItems = new ArrayList<>(); + final Set clipTypes = new HashSet<>(); + + PersistableBundle bundle = new PersistableBundle(); + bundle.putInt(OP_TYPE_KEY, opType); + + for (String id : selection) { + assert(id != null); + Uri uri = uriBuilder.apply(id); + DocumentInfo.addMimeTypes(resolver, uri, clipTypes); + clipItems.add(new ClipData.Item(uri)); + } + + ClipDescription description = new ClipDescription( + "", // Currently "label" is not displayed anywhere in the UI. + clipTypes.toArray(new String[0])); + description.setExtras(bundle); + + return createClipData(description, clipItems); + } + + /** + * Returns ClipData representing the list of docs + */ + private ClipData createJumboClipData( + Function uriBuilder, Selection selection, @OpType int opType) { + + assert(!selection.isEmpty()); + assert(selection.size() > Shared.MAX_DOCS_IN_INTENT); + + final List uris = new ArrayList<>(selection.size()); + + final int capacity = Math.min(selection.size(), Shared.MAX_DOCS_IN_INTENT); + final ArrayList clipItems = new ArrayList<>(capacity); + + // Set up mime types for the first Shared.MAX_DOCS_IN_INTENT + final ContentResolver resolver = mContext.getContentResolver(); + final Set clipTypes = new HashSet<>(); + int docCount = 0; + for (String id : selection) { + assert(id != null); + Uri uri = uriBuilder.apply(id); + if (docCount++ < Shared.MAX_DOCS_IN_INTENT) { + DocumentInfo.addMimeTypes(resolver, uri, clipTypes); + clipItems.add(new ClipData.Item(uri)); + } + + uris.add(uri); + } + + // Prepare metadata + PersistableBundle bundle = new PersistableBundle(); + bundle.putInt(OP_TYPE_KEY, opType); + bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size()); + + // Persists clip items and gets the slot they were saved under. + int tag = mClipStore.persistUris(uris); + bundle.putInt(OP_JUMBO_SELECTION_TAG, tag); + + ClipDescription description = new ClipDescription( + "", // Currently "label" is not displayed anywhere in the UI. + clipTypes.toArray(new String[0])); + description.setExtras(bundle); + + return createClipData(description, clipItems); + } + + @Override + public void clipDocumentsForCopy(Function uriBuilder, Selection selection) { + ClipData data = + getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY); + assert(data != null); + + mClipboard.setPrimaryClip(data); + } + + @Override + public void clipDocumentsForCut( + Function uriBuilder, Selection selection, DocumentInfo parent) { + assert(!selection.isEmpty()); + assert(parent.derivedUri != null); + + ClipData data = getClipDataForDocuments(uriBuilder, selection, + FileOperationService.OPERATION_MOVE); + assert(data != null); + + PersistableBundle bundle = data.getDescription().getExtras(); + bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString()); + + mClipboard.setPrimaryClip(data); + } + + + @Override + public void copyFromClipboard( + DocumentInfo destination, + DocumentStack docStack, + FileOperations.Callback callback) { + + copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback); + } + + @Override + public void copyFromClipboard( + DocumentStack docStack, + FileOperations.Callback callback) { + + copyFromClipData(docStack, mClipboard.getPrimaryClip(), callback); + } + + @Override + public void copyFromClipData( + final RootInfo root, + final @Nullable DocumentInfo destination, + final @Nullable ClipData clipData, + final FileOperations.Callback callback) { + DocumentStack dstStack = new DocumentStack(root, destination); + copyFromClipData(dstStack, clipData, callback); + } + + @Override + public void copyFromClipData( + final @Nullable DocumentInfo destination, + final DocumentStack docStack, + final @Nullable ClipData clipData, + final FileOperations.Callback callback) { + + DocumentStack dstStack = new DocumentStack(docStack, destination); + copyFromClipData(dstStack, clipData, callback); + } + + /** + * Copies documents from given clip data to a folder. + * + * @param dstStack the document stack to the destination folder, including the destination + * folder. + * @param clipData the clipData to copy from + * @param callback callback to notify when operation finishes + */ + private void copyFromClipData( + final DocumentStack dstStack, + final @Nullable ClipData clipData, + final FileOperations.Callback callback) { + + if (clipData == null) { + Log.i(TAG, "Received null clipData. Ignoring."); + return; + } + + PersistableBundle bundle = clipData.getDescription().getExtras(); + @OpType int opType = getOpType(bundle); + try { + if (!canCopy(dstStack.peek())) { + callback.onOperationResult( + FileOperations.Callback.STATUS_REJECTED, getOpType(clipData), 0); + return; + } + + UrisSupplier uris = UrisSupplier.create(clipData, mClipStore); + if (uris.getItemCount() == 0) { + callback.onOperationResult( + FileOperations.Callback.STATUS_ACCEPTED, opType, 0); + return; + } + + String srcParentString = bundle.getString(SRC_PARENT_KEY); + Uri srcParent = srcParentString == null ? null : Uri.parse(srcParentString); + + FileOperation operation = new FileOperation.Builder() + .withOpType(opType) + .withSrcParent(srcParent) + .withDestination(dstStack) + .withSrcs(uris) + .build(); + + FileOperations.start(mContext, operation, callback); + } catch(IOException e) { + Log.e(TAG, "Cannot create uris supplier.", e); + callback.onOperationResult(FileOperations.Callback.STATUS_REJECTED, opType, 0); + return; + } + } + + /** + * Returns true if the list of files can be copied to destination. Note that this + * is a policy check only. Currently the method does not attempt to verify + * available space or any other environmental aspects possibly resulting in + * failure to copy. + * + * @return true if the list of files can be copied to destination. + */ + private static boolean canCopy(@Nullable DocumentInfo dest) { + return dest != null && dest.isDirectory() && dest.isCreateSupported(); + } + + @Override + public @OpType int getOpType(ClipData data) { + PersistableBundle bundle = data.getDescription().getExtras(); + return getOpType(bundle); + } + + private @OpType int getOpType(PersistableBundle bundle) { + return bundle.getInt(OP_TYPE_KEY); + } + + private static ClipData createClipData( + ClipDescription description, ArrayList clipItems) { + + // technically we want to check >= O, but we'd need to patch back the O version code :| + if (Features.OMC_RUNTIME) { + return new ClipData(description, clipItems); + } + + ClipData clip = new ClipData(description, clipItems.get(0)); + for (int i = 1; i < clipItems.size(); i++) { + clip.addItem(clipItems.get(i)); + } + return clip; + } +} diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java index d3c19a705..588a4d0e8 100644 --- a/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -34,7 +34,6 @@ import android.app.FragmentTransaction; import android.content.ClipData; import android.content.Context; import android.content.Intent; -import android.content.Loader; import android.content.res.Resources; import android.database.Cursor; import android.graphics.drawable.StateListDrawable; @@ -956,7 +955,7 @@ public class DirectoryFragment extends Fragment ClipData clipData = event.getClipData(); assert (clipData != null); - assert(DocumentClipper.getOpType(clipData) == FileOperationService.OPERATION_COPY); + assert(mClipper.getOpType(clipData) == FileOperationService.OPERATION_COPY); if (!DragAndDropHelper.canCopyTo(event.getLocalState(), getDestination(v))) { return false; diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java index 6926fb81e..f8071d2bb 100644 --- a/src/com/android/documentsui/files/ActionHandler.java +++ b/src/com/android/documentsui/files/ActionHandler.java @@ -20,6 +20,7 @@ import static com.android.documentsui.base.Shared.DEBUG; import android.app.Activity; import android.content.ActivityNotFoundException; +import android.content.ClipData; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Intent; @@ -36,7 +37,9 @@ import com.android.documentsui.DocumentsApplication; import com.android.documentsui.DragAndDropHelper; import com.android.documentsui.Injector; import com.android.documentsui.Metrics; +import com.android.documentsui.Model; import com.android.documentsui.R; +import com.android.documentsui.TimeoutTask; import com.android.documentsui.base.ConfirmationCallback; import com.android.documentsui.base.ConfirmationCallback.Result; import com.android.documentsui.base.DocumentFilters; @@ -53,10 +56,8 @@ import com.android.documentsui.clipping.DocumentClipper; import com.android.documentsui.clipping.UrisSupplier; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.DocumentDetails; -import com.android.documentsui.Model; import com.android.documentsui.files.ActionHandler.Addons; import com.android.documentsui.queries.SearchViewManager; -import com.android.documentsui.roots.GetRootDocumentTask; import com.android.documentsui.roots.RootsAccess; import com.android.documentsui.selection.Selection; import com.android.documentsui.services.FileOperation; @@ -111,21 +112,26 @@ public class ActionHandler extends AbstractActionHa @Override public boolean dropOn(DragEvent event, RootInfo root) { - new GetRootDocumentTask( + // DragEvent gets recycled, so it is possible that by the time the callback is called, + // event.getLocalState() and event.getClipData() returns null. Thus, we want to save + // references to ensure they are non null. + final ClipData clipData = event.getClipData(); + final Object localState = event.getLocalState(); + getRootDocument( root, - mActivity, - (DocumentInfo rootDoc) -> dropOnCallback(event, rootDoc, root) - ).executeOnExecutor(mExecutors.lookup(root.authority)); + TimeoutTask.DEFAULT_TIMEOUT, + (DocumentInfo rootDoc) -> dropOnCallback(clipData, localState, rootDoc, root)); return true; } - private void dropOnCallback(DragEvent event, DocumentInfo rootDoc, RootInfo root) { - if (!DragAndDropHelper.canCopyTo(event.getLocalState(), rootDoc)) { + private void dropOnCallback( + ClipData clipData, Object localState, DocumentInfo rootDoc, RootInfo root) { + if (!DragAndDropHelper.canCopyTo(localState, rootDoc)) { return; } mClipper.copyFromClipData( - root, rootDoc, event.getClipData(), mDialogs::showFileOperationStatus); + root, rootDoc, clipData, mDialogs::showFileOperationStatus); } @Override @@ -147,17 +153,15 @@ public class ActionHandler extends AbstractActionHa @Override public void pasteIntoFolder(RootInfo root) { - new GetRootDocumentTask( + this.getRootDocument( root, - mActivity, - (DocumentInfo doc) -> pasteIntoFolder(root, doc) - ).executeOnExecutor(mExecutors.lookup(root.authority)); + TimeoutTask.DEFAULT_TIMEOUT, + (DocumentInfo doc) -> pasteIntoFolder(root, doc)); } private void pasteIntoFolder(RootInfo root, @Nullable DocumentInfo doc) { - DocumentClipper clipper = DocumentsApplication.getDocumentClipper(mActivity); DocumentStack stack = new DocumentStack(root, doc); - clipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationStatus); + mClipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationStatus); } @Override diff --git a/src/com/android/documentsui/files/FilesActivity.java b/src/com/android/documentsui/files/FilesActivity.java index c90948859..3b0223962 100644 --- a/src/com/android/documentsui/files/FilesActivity.java +++ b/src/com/android/documentsui/files/FilesActivity.java @@ -17,16 +17,12 @@ package com.android.documentsui.files; import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_UNKNOWN; -import static com.android.documentsui.base.Shared.DEBUG; -import android.app.Activity; import android.app.FragmentManager; -import android.content.ClipData; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.support.annotation.CallSuper; -import android.util.Log; import android.view.KeyEvent; import android.view.KeyboardShortcutGroup; import android.view.Menu; @@ -45,15 +41,13 @@ import com.android.documentsui.ProviderExecutor; import com.android.documentsui.R; import com.android.documentsui.SharedInputHandler; import com.android.documentsui.base.DocumentInfo; -import com.android.documentsui.base.DocumentStack; import com.android.documentsui.base.Features; import com.android.documentsui.base.RootInfo; -import com.android.documentsui.base.Shared; import com.android.documentsui.base.State; import com.android.documentsui.clipping.DocumentClipper; import com.android.documentsui.dirlist.AnimationView.AnimationType; -import com.android.documentsui.prefs.ScopedPreferences; import com.android.documentsui.dirlist.DirectoryFragment; +import com.android.documentsui.prefs.ScopedPreferences; import com.android.documentsui.selection.SelectionManager; import com.android.documentsui.services.FileOperationService; import com.android.documentsui.sidebar.RootsFragment; @@ -61,7 +55,6 @@ import com.android.documentsui.ui.DialogController; import com.android.documentsui.ui.MessageBuilder; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** diff --git a/src/com/android/documentsui/roots/GetRootDocumentTask.java b/src/com/android/documentsui/roots/GetRootDocumentTask.java index e7fdadf75..84b58122f 100644 --- a/src/com/android/documentsui/roots/GetRootDocumentTask.java +++ b/src/com/android/documentsui/roots/GetRootDocumentTask.java @@ -18,10 +18,9 @@ package com.android.documentsui.roots; import android.annotation.Nullable; import android.app.Activity; -import android.app.Fragment; -import android.content.Context; import android.util.Log; +import com.android.documentsui.DocumentsAccess; import com.android.documentsui.TimeoutTask; import com.android.documentsui.base.CheckedTask; import com.android.documentsui.base.DocumentInfo; @@ -39,20 +38,24 @@ public class GetRootDocumentTask extends TimeoutTask { private final static String TAG = "GetRootDocumentTask"; private final RootInfo mRootInfo; - private final Context mContext; private final Consumer mCallback; + private final DocumentsAccess mDocs; public GetRootDocumentTask( - RootInfo rootInfo, Activity activity, Consumer callback) { - super(activity::isDestroyed); + RootInfo rootInfo, + Activity activity, + long timeout, + DocumentsAccess docs, + Consumer callback) { + super(activity::isDestroyed, timeout); mRootInfo = rootInfo; - mContext = activity.getApplicationContext(); + mDocs = docs; mCallback = callback; } @Override - public @Nullable DocumentInfo run(Void... rootInfo) { - return mRootInfo.getRootDocumentBlocking(mContext); + public @Nullable DocumentInfo run(Void... args) { + return mDocs.getRootDocument(mRootInfo); } @Override diff --git a/src/com/android/documentsui/sidebar/RootsFragment.java b/src/com/android/documentsui/sidebar/RootsFragment.java index 1d4950f82..299ac578e 100644 --- a/src/com/android/documentsui/sidebar/RootsFragment.java +++ b/src/com/android/documentsui/sidebar/RootsFragment.java @@ -56,6 +56,7 @@ import com.android.documentsui.Injector; import com.android.documentsui.Injector.Injected; import com.android.documentsui.ItemDragListener; import com.android.documentsui.R; +import com.android.documentsui.TimeoutTask; import com.android.documentsui.base.BooleanConsumer; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; @@ -473,14 +474,12 @@ public class RootsFragment extends Fragment implements ItemDragListener.DragHost private void getRootDocument(RootItem rootItem, RootUpdater updater) { // We need to start a GetRootDocumentTask so we can know whether items can be directly // pasted into root - GetRootDocumentTask task = new GetRootDocumentTask( + mActionHandler.getRootDocument( rootItem.root, - getBaseActivity(), + CONTEXT_MENU_ITEM_TIMEOUT, (DocumentInfo doc) -> { updater.updateDocInfoForRoot(doc); }); - task.setTimeout(CONTEXT_MENU_ITEM_TIMEOUT); - task.executeOnExecutor(getBaseActivity().getExecutorForCurrentDirectory()); } static void ejectClicked(View ejectIcon, RootInfo root, ActionHandler actionHandler) { diff --git a/tests/common/com/android/documentsui/testing/TestDocumentClipper.java b/tests/common/com/android/documentsui/testing/TestDocumentClipper.java new file mode 100644 index 000000000..2e5924b02 --- /dev/null +++ b/tests/common/com/android/documentsui/testing/TestDocumentClipper.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.testing; + +import android.content.ClipData; +import android.net.Uri; + +import com.android.documentsui.base.DocumentInfo; +import com.android.documentsui.base.DocumentStack; +import com.android.documentsui.base.RootInfo; +import com.android.documentsui.clipping.DocumentClipper; +import com.android.documentsui.selection.Selection; +import com.android.documentsui.services.FileOperations.Callback; + +import java.util.function.Function; + +public class TestDocumentClipper implements DocumentClipper { + + @Override + public boolean hasItemsToPaste() { + return false; + } + + @Override + public ClipData getClipDataForDocuments(Function uriBuilder, Selection selection, + int opType) { + return null; + } + + @Override + public void clipDocumentsForCopy(Function uriBuilder, Selection selection) { + + } + + @Override + public void clipDocumentsForCut(Function uriBuilder, Selection selection, + DocumentInfo parent) { + } + + @Override + public void copyFromClipboard(DocumentInfo destination, DocumentStack docStack, + Callback callback) { + } + + @Override + public void copyFromClipboard(DocumentStack docStack, Callback callback) { + } + + @Override + public void copyFromClipData(RootInfo root, DocumentInfo destination, ClipData clipData, + Callback callback) { + } + + @Override + public void copyFromClipData(DocumentInfo destination, DocumentStack docStack, + ClipData clipData, Callback callback) { + } + + @Override + public int getOpType(ClipData data) { + return 0; + } + + +} diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java index 3f1eb8169..d5b7379d9 100644 --- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java +++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java @@ -24,8 +24,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import android.content.ClipData; import android.content.Intent; import android.net.Uri; import android.os.Parcelable; @@ -33,6 +35,7 @@ import android.provider.DocumentsContract; import android.provider.DocumentsContract.Path; import android.support.test.filters.MediumTest; import android.support.test.runner.AndroidJUnit4; +import android.view.DragEvent; import com.android.documentsui.R; import com.android.documentsui.TestActionModeAddons; @@ -41,9 +44,12 @@ import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.Shared; +import com.android.documentsui.services.FileOperations.Callback; +import com.android.documentsui.testing.ClipDatas; import com.android.documentsui.testing.DocumentStackAsserts; import com.android.documentsui.testing.Roots; import com.android.documentsui.testing.TestConfirmationCallback; +import com.android.documentsui.testing.TestDocumentClipper; import com.android.documentsui.testing.TestEnv; import com.android.documentsui.testing.TestRootsAccess; import com.android.documentsui.ui.TestDialogController; @@ -52,7 +58,9 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; @RunWith(AndroidJUnit4.class) @MediumTest @@ -65,6 +73,7 @@ public class ActionHandlerTest { private TestConfirmationCallback mCallback; private ActionHandler mHandler; private boolean refreshAnswer = false; + private ClipData mClipDataFromCallback; @Before public void setUp() { @@ -81,6 +90,8 @@ public class ActionHandlerTest { mDialogs.confirmNext(); mEnv.selectDocument(TestEnv.FILE_GIF); + + mClipDataFromCallback = null; } @Test @@ -351,6 +362,83 @@ public class ActionHandlerTest { mActivity.refreshCurrentRootAndDirectory.assertCalled(); } + @Test + public void testClipper_suppliedCorrectClipData() throws Exception { + // DragEvent gets recycled in Android, so it is possible that by the time the callback is + // called, event.getLocalState() and event.getClipData() returns null. This tests to ensure + // our Clipper is getting the original CipData passed in. + mHandler = new ActionHandler<>( + mActivity, + mEnv.state, + mEnv.roots, + mEnv.docs, + mEnv.searchViewManager, + mEnv::lookupExecutor, + mActionModeAddons, + new TestDocumentClipper() { + @Override + public void copyFromClipData( + RootInfo root, + DocumentInfo destination, + ClipData clipData, + Callback callback) { + mClipDataFromCallback = clipData; + } + }, + null, + mEnv.injector + ); + Object localState = new Object(); + ClipData clipData = ClipDatas.createTestClipData(); + DragEvent event = DragEvent.obtain(DragEvent.ACTION_DROP, 1, 1, localState, null, clipData, + null, true); + assertSame(localState, event.getLocalState()); + assertSame(clipData, event.getClipData()); + + mHandler.dropOn(event, TestRootsAccess.DOWNLOADS); + event.recycle(); + + mEnv.beforeAsserts(); + + assertSame(clipData, mClipDataFromCallback); + } + + @Test + public void testClipper_notCalledIfDestInSelection() throws Exception { + mHandler = new ActionHandler<>( + mActivity, + mEnv.state, + mEnv.roots, + mEnv.docs, + mEnv.searchViewManager, + mEnv::lookupExecutor, + mActionModeAddons, + new TestDocumentClipper() { + @Override + public void copyFromClipData( + RootInfo root, + DocumentInfo destination, + ClipData clipData, + Callback callback) { + mClipDataFromCallback = clipData; + } + }, + null, + mEnv.injector + ); + List localState = new ArrayList<>(); + localState.add(mEnv.docs.getRootDocument(TestRootsAccess.DOWNLOADS)); + ClipData clipData = ClipDatas.createTestClipData(); + DragEvent event = DragEvent.obtain(DragEvent.ACTION_DROP, 1, 1, localState, null, clipData, + null, true); + + mHandler.dropOn(event, TestRootsAccess.DOWNLOADS); + + mEnv.beforeAsserts(); + + assertNull(mClipDataFromCallback); + } + @Test public void testRefresh_nullUri() throws Exception { refreshAnswer = true; @@ -404,8 +492,8 @@ public class ActionHandlerTest { mEnv.searchViewManager, mEnv::lookupExecutor, mActionModeAddons, - null, // clipper, only used in drag/drop - null, // clip storage, not utilized unless we venture into *jumbo* clip terratory. + new TestDocumentClipper(), + null, // clip storage, not utilized unless we venture into *jumbo* clip territory. mEnv.injector ); } -- cgit v1.2.3-59-g8ed1b