diff options
author | TreeHugger Robot <treehugger-gerrit@google.com> | 2016-06-17 01:33:47 +0000 |
---|---|---|
committer | Android (Google) Code Review <android-gerrit@google.com> | 2016-06-17 01:33:49 +0000 |
commit | ff244e6c4c9597e9430b936e89d58e4b49f7d09f (patch) | |
tree | bf3a7f66b5fa9f7bbfcd8fbdba4e856b0a88f80f | |
parent | f604c8395584663bb45e92d25b602516e448bc39 (diff) | |
parent | d81879670be7cbf6e94d673e815e758e9e3ae3c8 (diff) |
Merge "[multi-part] Eliminate 1k selection limit"
14 files changed, 696 insertions, 236 deletions
diff --git a/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java b/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java new file mode 100644 index 000000000000..0167accc352d --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java @@ -0,0 +1,136 @@ +/* + * 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; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Provides support for storing lists of documents identified by Uri. + * + * <li>Access to this object *must* be synchronized externally. + * <li>All calls to this class are I/O intensive and must be wrapped in an AsyncTask. + */ +public final class ClipStorage { + + private static final String PRIMARY_SELECTION = "primary-selection.txt"; + private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes(); + private static final int NO_SELECTION_TAG = -1; + + private final File mOutDir; + + /** + * @param outDir see {@link #prepareStorage(File)}. + */ + public ClipStorage(File outDir) { + assert(outDir.isDirectory()); + mOutDir = outDir; + } + + /** + * Returns a writer. Callers must... + * + * <li>synchronize on the {@link ClipStorage} instance while writing to this writer. + * <li>closed the write when finished. + */ + public Writer createWriter() throws IOException { + File primary = new File(mOutDir, PRIMARY_SELECTION); + return new Writer(new FileOutputStream(primary)); + } + + /** + * Saves primary uri list to persistent storage. + * @return tag identifying the saved set. + */ + @VisibleForTesting + public long savePrimary() throws IOException { + File primary = new File(mOutDir, PRIMARY_SELECTION); + + if (!primary.exists()) { + return NO_SELECTION_TAG; + } + + long tag = System.currentTimeMillis(); + File dest = toTagFile(tag); + primary.renameTo(dest); + + return tag; + } + + @VisibleForTesting + public List<Uri> read(long tag) throws IOException { + List<Uri> uris = new ArrayList<>(); + File tagFile = toTagFile(tag); + try (BufferedReader in = new BufferedReader(new FileReader(tagFile))) { + String line = null; + while ((line = in.readLine()) != null) { + uris.add(Uri.parse(line)); + } + } + return uris; + } + + @VisibleForTesting + public void delete(long tag) throws IOException { + toTagFile(tag).delete(); + } + + private File toTagFile(long tag) { + return new File(mOutDir, String.valueOf(tag)); + } + + public static final class Writer implements Closeable { + + private final FileOutputStream mOut; + + public Writer(FileOutputStream out) { + mOut = out; + } + + public void write(Uri uri) throws IOException { + mOut.write(uri.toString().getBytes()); + mOut.write(LINE_SEPARATOR); + } + + @Override + public void close() throws IOException { + mOut.close(); + } + } + + /** + * Provides initialization and cleanup of the clip data storage directory. + */ + static File prepareStorage(File cacheDir) { + File clipDir = new File(cacheDir, "clippings"); + if (clipDir.exists()) { + Files.deleteRecursively(clipDir); + } + assert(!clipDir.exists()); + clipDir.mkdir(); + return clipDir; + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java b/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java index cc9ab978295a..3d8ac2c936a6 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java @@ -17,6 +17,7 @@ package com.android.documentsui; import android.content.ClipData; +import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.Context; @@ -27,6 +28,8 @@ import android.provider.DocumentsContract; import android.support.annotation.Nullable; import android.util.Log; +import com.android.documentsui.ClipStorage.Writer; +import com.android.documentsui.dirlist.MultiSelectManager.Selection; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.model.RootInfo; @@ -34,11 +37,13 @@ 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.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.function.Function; /** * ClipboardManager wrapper class providing higher level logical @@ -49,12 +54,15 @@ 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 static final String OP_JUMBO_SELECTION_SIZE = "jumboSelection-size"; - private Context mContext; - private ClipboardManager mClipboard; + private final Context mContext; + private final ClipStorage mClipStorage; + private final ClipboardManager mClipboard; - DocumentClipper(Context context) { + DocumentClipper(Context context, ClipStorage storage) { mContext = context; + mClipStorage = storage; mClipboard = context.getSystemService(ClipboardManager.class); } @@ -79,13 +87,6 @@ public final class DocumentClipper { return uri != null && DocumentsContract.isDocumentUri(mContext, uri); } - /** - * Returns details regarding the documents on the primary clipboard - */ - public ClipDetails getClipDetails() { - return getClipDetails(mClipboard.getPrimaryClip()); - } - public ClipDetails getClipDetails(@Nullable ClipData clipData) { if (clipData == null) { return null; @@ -127,54 +128,108 @@ public final class DocumentClipper { } /** - * Returns ClipData representing the list of docs, or null if docs is empty, - * or docs cannot be converted. + * Returns {@link ClipData} representing the selection, or null if selection is empty, + * or cannot be converted. + */ + public @Nullable ClipData getClipDataForDocuments( + Function<String, Uri> 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. */ - public @Nullable ClipData getClipDataForDocuments(List<DocumentInfo> docs, @OpType int opType) { + private @Nullable ClipData createStandardClipData( + Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { + + assert(!selection.isEmpty()); + final ContentResolver resolver = mContext.getContentResolver(); - final String[] mimeTypes = getMimeTypes(resolver, docs); - ClipData clipData = null; - for (DocumentInfo doc : docs) { - 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 clipLabel = ""; - clipData = new ClipData(clipLabel, mimeTypes, new ClipData.Item(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(doc.derivedUri)); + final ArrayList<ClipData.Item> clipItems = new ArrayList<>(); + final Set<String> clipTypes = new HashSet<>(); + + PersistableBundle bundle = new PersistableBundle(); + bundle.putInt(OP_TYPE_KEY, opType); + + int clipCount = 0; + for (String id : selection) { + assert(id != null); + Uri uri = uriBuilder.apply(id); + if (clipCount <= Shared.MAX_DOCS_IN_INTENT) { + DocumentInfo.addMimeTypes(resolver, uri, clipTypes); + clipItems.add(new ClipData.Item(uri)); } + clipCount++; } - return clipData; + + ClipDescription description = new ClipDescription( + "", // Currently "label" is not displayed anywhere in the UI. + clipTypes.toArray(new String[0])); + description.setExtras(bundle); + + return new ClipData(description, clipItems); } - private static String[] getMimeTypes(ContentResolver resolver, List<DocumentInfo> docs) { - final HashSet<String> mimeTypes = new HashSet<>(); - for (DocumentInfo doc : docs) { - assert(doc != null); - assert(doc.derivedUri != null); - final Uri uri = doc.derivedUri; - if ("content".equals(uri.getScheme())) { - mimeTypes.add(resolver.getType(uri)); - final String[] streamTypes = resolver.getStreamTypes(uri, "*/*"); - if (streamTypes != null) { - mimeTypes.addAll(Arrays.asList(streamTypes)); + /** + * Returns ClipData representing the list of docs, or null if docs is empty, + * or docs cannot be converted. + */ + private @Nullable ClipData createJumboClipData( + Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { + + assert(!selection.isEmpty()); + + final ContentResolver resolver = mContext.getContentResolver(); + final ArrayList<ClipData.Item> clipItems = new ArrayList<>(); + final Set<String> clipTypes = new HashSet<>(); + + PersistableBundle bundle = new PersistableBundle(); + bundle.putInt(OP_TYPE_KEY, opType); + bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size()); + + int clipCount = 0; + synchronized (mClipStorage) { + try (Writer writer = mClipStorage.createWriter()) { + for (String id : selection) { + assert(id != null); + Uri uri = uriBuilder.apply(id); + if (clipCount <= Shared.MAX_DOCS_IN_INTENT) { + DocumentInfo.addMimeTypes(resolver, uri, clipTypes); + clipItems.add(new ClipData.Item(uri)); + } + writer.write(uri); + clipCount++; } + } catch (IOException e) { + Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e); + return null; } } - return mimeTypes.toArray(new String[0]); + + ClipDescription description = new ClipDescription( + "", // Currently "label" is not displayed anywhere in the UI. + clipTypes.toArray(new String[0])); + description.setExtras(bundle); + + return new ClipData(description, clipItems); } /** * Puts {@code ClipData} in a primary clipboard, describing a copy operation */ - public void clipDocumentsForCopy(List<DocumentInfo> docs) { - ClipData data = getClipDataForDocuments(docs, FileOperationService.OPERATION_COPY); + public void clipDocumentsForCopy(Function<String, Uri> uriBuilder, Selection selection) { + ClipData data = + getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY); assert(data != null); mClipboard.setPrimaryClip(data); @@ -183,24 +238,24 @@ public final class DocumentClipper { /** * 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); + public void clipDocumentsForCut( + Function<String, Uri> uriBuilder, Selection selection, DocumentInfo parent) { + assert(!selection.isEmpty()); + assert(parent.derivedUri != null); - ClipData data = getClipDataForDocuments(docs, FileOperationService.OPERATION_MOVE); + ClipData data = getClipDataForDocuments(uriBuilder, selection, + FileOperationService.OPERATION_MOVE); assert(data != null); PersistableBundle bundle = data.getDescription().getExtras(); - bundle.putString(SRC_PARENT_KEY, srcParent.derivedUri.toString()); + bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString()); mClipboard.setPrimaryClip(data); } private DocumentInfo createDocument(Uri uri) { DocumentInfo doc = null; - if (uri != null && DocumentsContract.isDocumentUri(mContext, uri)) { + if (isDocumentUri(uri)) { ContentResolver resolver = mContext.getContentResolver(); try { doc = DocumentInfo.fromUri(resolver, uri); @@ -219,8 +274,11 @@ public final class DocumentClipper { * @param docStack the document stack to the destination folder, * @param callback callback to notify when operation finishes. */ - public void copyFromClipboard(DocumentInfo destination, DocumentStack docStack, + public void copyFromClipboard( + DocumentInfo destination, + DocumentStack docStack, FileOperations.Callback callback) { + copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback); } @@ -232,8 +290,12 @@ public final class DocumentClipper { * @param clipData the clipData to copy from, or null to copy from clipboard * @param callback callback to notify when operation finishes */ - public void copyFromClipData(final DocumentInfo destination, DocumentStack docStack, - @Nullable final ClipData clipData, final FileOperations.Callback callback) { + public void copyFromClipData( + final DocumentInfo destination, + DocumentStack docStack, + final @Nullable ClipData clipData, + final FileOperations.Callback callback) { + if (clipData == null) { Log.i(TAG, "Received null clipData. Ignoring."); return; @@ -308,7 +370,7 @@ public final class DocumentClipper { * * @return true if the list of files can be copied to destination. */ - private boolean canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest) { + private static boolean canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest) { if (dest == null || !dest.isDirectory() || !dest.isCreateSupported()) { return false; } diff --git a/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java b/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java index 0c615018558a..2b2d1f40060b 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java @@ -28,13 +28,14 @@ import android.net.Uri; import android.os.RemoteException; import android.text.format.DateUtils; +import java.io.File; + public class DocumentsApplication extends Application { private static final long PROVIDER_ANR_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS; private RootsCache mRoots; private ThumbnailCache mThumbnailCache; - private DocumentClipper mClipper; public static RootsCache getRootsCache(Context context) { @@ -73,7 +74,7 @@ public class DocumentsApplication extends Application { mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4); - mClipper = new DocumentClipper(this); + mClipper = createClipper(this.getApplicationContext()); final IntentFilter packageFilter = new IntentFilter(); packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); @@ -88,6 +89,12 @@ public class DocumentsApplication extends Application { registerReceiver(mCacheReceiver, localeFilter); } + private static DocumentClipper createClipper(Context context) { + // prepare storage handles initialization and cleanup of the clip directory. + File clipDir = ClipStorage.prepareStorage(context.getCacheDir()); + return new DocumentClipper(context, new ClipStorage(clipDir)); + } + @Override public void onTrimMemory(int level) { super.onTrimMemory(level); diff --git a/packages/DocumentsUI/src/com/android/documentsui/Files.java b/packages/DocumentsUI/src/com/android/documentsui/Files.java new file mode 100644 index 000000000000..38f98be95912 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/Files.java @@ -0,0 +1,38 @@ +/* + * 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; + +import java.io.File; + +/** + * Utility class for working with {@link File} instances. + */ +public final class Files { + + private Files() {} // no initialization for utility classes. + + public static void deleteRecursively(File file) { + if (file.exists()) { + if (file.isDirectory()) { + for (File child : file.listFiles()) { + deleteRecursively(child); + } + } + file.delete(); + } + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java index be20c0e26e8f..091ae6db6968 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java +++ b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java @@ -487,7 +487,7 @@ public class FilesActivity extends BaseActivity { } @Override - protected Void run(Uri... params) { + public Void run(Uri... params) { final Uri uri = params[0]; final RootsCache rootsCache = DocumentsApplication.getRootsCache(mOwner); @@ -512,7 +512,7 @@ public class FilesActivity extends BaseActivity { } @Override - protected void finish(Void result) { + public void finish(Void result) { mOwner.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); } } diff --git a/packages/DocumentsUI/src/com/android/documentsui/Shared.java b/packages/DocumentsUI/src/com/android/documentsui/Shared.java index 24755f39d998..2a81c4830dc4 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/Shared.java +++ b/packages/DocumentsUI/src/com/android/documentsui/Shared.java @@ -110,7 +110,7 @@ public final class Shared { /** * Maximum number of items in a Binder transaction packet. */ - public static final int MAX_DOCS_IN_INTENT = 1000; + public static final int MAX_DOCS_IN_INTENT = 500; private static final Collator sCollator; diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java new file mode 100644 index 000000000000..3aefffbb2f13 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java @@ -0,0 +1,61 @@ +/* + * 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.dirlist; + +import android.app.Activity; +import android.os.AsyncTask; +import android.support.design.widget.Snackbar; + +import com.android.documentsui.R; +import com.android.documentsui.Shared; +import com.android.documentsui.Snackbars; + +/** + * AsyncTask that performs a supplied runnable (presumably doing some clippy thing)in background, + * then shows a toast reciting how many fantastic things have been clipped. + */ +final class ClipTask extends AsyncTask<Void, Void, Void> { + + private Runnable mOperation; + private int mSelectionSize; + private Activity mActivity; + + ClipTask(Activity activity, Runnable operation, int selectionSize) { + mActivity = activity; + mOperation = operation; + mSelectionSize = selectionSize; + } + + @Override + protected Void doInBackground(Void... params) { + // Clip operation varies (cut or past) and has different inputs. + // To increase sharing we accept the no ins/outs operation as a plain runnable. + mOperation.run(); + return null; + } + + @Override + protected void onPostExecute(Void result) { + String msg = Shared.getQuantityString( + mActivity, + R.plurals.clipboard_files_clipped, + mSelectionSize); + + Snackbars.makeSnackbar(mActivity, msg, Snackbar.LENGTH_SHORT) + .show(); + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java index 7f35854dfc53..8e9bf3e199f8 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -38,9 +38,6 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.Loader; import android.database.Cursor; -import android.graphics.Canvas; -import android.graphics.Point; -import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; @@ -48,7 +45,6 @@ import android.os.Bundle; import android.os.Parcelable; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; -import android.support.design.widget.Snackbar; import android.support.v13.view.DragStartHelper; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.GridLayoutManager.SpanSizeLookup; @@ -109,8 +105,6 @@ import com.android.documentsui.services.FileOperationService; import com.android.documentsui.services.FileOperationService.OpType; import com.android.documentsui.services.FileOperations; -import com.google.common.collect.Lists; - import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; @@ -966,6 +960,7 @@ public class DirectoryFragment extends Fragment .setPositiveButton( android.R.string.ok, new DialogInterface.OnClickListener() { + @Override public void onClick(DialogInterface dialog, int id) { // Finish selection mode first which clears selection so we // don't end up trying to deselect deleted documents. @@ -1156,19 +1151,15 @@ public class DirectoryFragment extends Fragment 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(); + + // Clips the docs in the background, then displays a message + new ClipTask( + getActivity(), + () -> { + mClipper.clipDocumentsForCopy(mModel::getItemUri, selection); + }, + selection.size()).execute(); } public void cutSelectedToClipboard() { @@ -1178,21 +1169,18 @@ public class DirectoryFragment extends Fragment 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(); + + // Clips the docs in the background, then displays a message + new ClipTask( + getActivity(), + () -> { + mClipper.clipDocumentsForCut( + mModel::getItemUri, + selection, + getDisplayState().stack.peek()); + }, + selection.size()).execute(); } public void pasteFromClipboard() { @@ -1375,94 +1363,6 @@ public class DirectoryFragment extends Fragment return null; } - private List<DocumentInfo> getDraggableDocuments(View currentItemView) { - String modelId = getModelId(currentItemView); - if (modelId == null) { - return Collections.EMPTY_LIST; - } - - final List<DocumentInfo> selectedDocs = - mModel.getDocuments(mSelectionManager.getSelection()); - if (!selectedDocs.isEmpty()) { - if (!isSelected(modelId)) { - // There is a selection that does not include the current item, drag nothing. - return Collections.EMPTY_LIST; - } - return selectedDocs; - } - - final Cursor cursor = mModel.getItem(modelId); - if (cursor == null) { - Log.w(TAG, "Undraggable document. Can't obtain cursor for modelId " + modelId); - return Collections.EMPTY_LIST; - } - - return Lists.newArrayList( - DocumentInfo.fromDirectoryCursor(cursor)); - } - - private static class DragShadowBuilder extends View.DragShadowBuilder { - - private final Context mContext; - private final IconHelper mIconHelper; - private final LayoutInflater mInflater; - private final View mShadowView; - private final TextView mTitle; - private final ImageView mIcon; - private final int mWidth; - private final int mHeight; - - public DragShadowBuilder(Context context, IconHelper iconHelper, List<DocumentInfo> docs) { - mContext = context; - mIconHelper = iconHelper; - mInflater = LayoutInflater.from(context); - - mWidth = mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_width); - mHeight= mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_height); - - mShadowView = mInflater.inflate(R.layout.drag_shadow_layout, null); - mTitle = (TextView) mShadowView.findViewById(android.R.id.title); - mIcon = (ImageView) mShadowView.findViewById(android.R.id.icon); - - mTitle.setText(getTitle(docs)); - mIcon.setImageDrawable(getIcon(docs)); - } - - private Drawable getIcon(List<DocumentInfo> docs) { - if (docs.size() == 1) { - final DocumentInfo doc = docs.get(0); - return mIconHelper.getDocumentIcon(mContext, doc.authority, doc.documentId, - doc.mimeType, doc.icon); - } - return mContext.getDrawable(R.drawable.ic_doc_generic); - } - - private String getTitle(List<DocumentInfo> docs) { - if (docs.size() == 1) { - final DocumentInfo doc = docs.get(0); - return doc.displayName; - } - return Shared.getQuantityString(mContext, R.plurals.elements_dragged, docs.size()); - } - - @Override - public void onProvideShadowMetrics( - Point shadowSize, Point shadowTouchPoint) { - shadowSize.set(mWidth, mHeight); - shadowTouchPoint.set(mWidth, mHeight); - } - - @Override - public void onDrawShadow(Canvas canvas) { - Rect r = canvas.getClipBounds(); - // Calling measure is necessary in order for all child views to get correctly laid out. - mShadowView.measure( - View.MeasureSpec.makeMeasureSpec(r.right- r.left, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(r.top- r.bottom, View.MeasureSpec.EXACTLY)); - mShadowView.layout(r.left, r.top, r.right, r.bottom); - mShadowView.draw(canvas); - } - } /** * Abstract task providing support for loading documents *off* * the main thread. And if it isn't obvious, creating a list @@ -1615,29 +1515,68 @@ public class DirectoryFragment extends Fragment } } + private Drawable getDragIcon(Selection selection) { + if (selection.size() == 1) { + DocumentInfo doc = getSingleSelectedDocument(selection); + return mIconHelper.getDocumentIcon(getContext(), doc); + } + return getContext().getDrawable(R.drawable.ic_doc_generic); + } + + private String getDragTitle(Selection selection) { + assert (!selection.isEmpty()); + if (selection.size() == 1) { + DocumentInfo doc = getSingleSelectedDocument(selection); + return doc.displayName; + } + + return Shared.getQuantityString(getContext(), R.plurals.elements_dragged, selection.size()); + } + + private DocumentInfo getSingleSelectedDocument(Selection selection) { + assert (selection.size() == 1); + final List<DocumentInfo> docs = mModel.getDocuments(mSelectionManager.getSelection()); + assert (docs.size() == 1); + return docs.get(0); + } + private DragStartHelper.OnDragStartListener mOnDragStartListener = new DragStartHelper.OnDragStartListener() { - @Override - public boolean onDragStart(View v, DragStartHelper helper) { - if (isSelected(getModelId(v))) { - List<DocumentInfo> docs = getDraggableDocuments(v); - if (docs.isEmpty()) { - return false; + @Override + public boolean onDragStart(View v, DragStartHelper helper) { + Selection selection = mSelectionManager.getSelection(); + + if (v == null) { + Log.d(TAG, "Ignoring drag event, null view"); + return false; + } + if (!isSelected(getModelId(v))) { + Log.d(TAG, "Ignoring drag event, unselected view."); + return false; + } + + // NOTE: Preparation of the ClipData object can require a lot of time + // and ideally should be done in the background. Unfortunately + // the current code layout and framework assumptions don't support + // this. So for now, we could end up doing a bunch of i/o on main thread. + v.startDragAndDrop( + mClipper.getClipDataForDocuments( + mModel::getItemUri, + selection, + FileOperationService.OPERATION_COPY), + new DragShadowBuilder( + getActivity(), + getDragTitle(selection), + getDragIcon(selection)), + getDisplayState().stack.peek(), + View.DRAG_FLAG_GLOBAL + | View.DRAG_FLAG_GLOBAL_URI_READ + | View.DRAG_FLAG_GLOBAL_URI_WRITE); + + return true; } - v.startDragAndDrop( - mClipper.getClipDataForDocuments(docs, - FileOperationService.OPERATION_COPY), - new DragShadowBuilder(getActivity(), mIconHelper, docs), - getDisplayState().stack.peek(), - View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ | - View.DRAG_FLAG_GLOBAL_URI_WRITE - ); - return true; - } + }; - return false; - } - }; private DragStartHelper mDragHelper = new DragStartHelper(null, mOnDragStartListener); @@ -1892,7 +1831,7 @@ public class DirectoryFragment extends Fragment updateLayout(state.derivedMode); if (mRestoredSelection != null) { - mSelectionManager.setItemsSelected(mRestoredSelection.getAll(), true); + mSelectionManager.restoreSelection(mRestoredSelection); // Note, we'll take care of cleaning up retained selection // in the selection handler where we already have some // specialized code to handle when selection was restored. diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DragShadowBuilder.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DragShadowBuilder.java new file mode 100644 index 000000000000..c7d7a64c21e4 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DragShadowBuilder.java @@ -0,0 +1,68 @@ +/* + * 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.dirlist; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.documentsui.R; + +final class DragShadowBuilder extends View.DragShadowBuilder { + + private final View mShadowView; + private final TextView mTitle; + private final ImageView mIcon; + private final int mWidth; + private final int mHeight; + + public DragShadowBuilder(Context context, String title, Drawable icon) { + mWidth = context.getResources().getDimensionPixelSize(R.dimen.drag_shadow_width); + mHeight= context.getResources().getDimensionPixelSize(R.dimen.drag_shadow_height); + + mShadowView = LayoutInflater.from(context).inflate(R.layout.drag_shadow_layout, null); + mTitle = (TextView) mShadowView.findViewById(android.R.id.title); + mIcon = (ImageView) mShadowView.findViewById(android.R.id.icon); + + mTitle.setText(title); + mIcon.setImageDrawable(icon); + } + + @Override + public void onProvideShadowMetrics( + Point shadowSize, Point shadowTouchPoint) { + shadowSize.set(mWidth, mHeight); + shadowTouchPoint.set(mWidth, mHeight); + } + + @Override + public void onDrawShadow(Canvas canvas) { + Rect r = canvas.getClipBounds(); + // Calling measure is necessary in order for all child views to get correctly laid out. + mShadowView.measure( + View.MeasureSpec.makeMeasureSpec(r.right- r.left, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(r.top- r.bottom, View.MeasureSpec.EXACTLY)); + mShadowView.layout(r.left, r.top, r.right, r.bottom); + mShadowView.draw(canvas); + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/IconHelper.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/IconHelper.java index 0c7399273d2e..ec723140f5cd 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/IconHelper.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/IconHelper.java @@ -47,6 +47,7 @@ import com.android.documentsui.State; import com.android.documentsui.State.ViewMode; import com.android.documentsui.ThumbnailCache; import com.android.documentsui.ThumbnailCache.Result; +import com.android.documentsui.model.DocumentInfo; import java.util.function.BiConsumer; @@ -293,22 +294,20 @@ public class IconHelper { view.setAlpha(0f); } - /** - * Gets a mime icon or package icon for a file. - * - * @param context - * @param authority The authority string of the file. - * @param id The document ID of the file. - * @param mimeType The mime type of the file. - * @param icon The custom icon (if any) of the file. - * @return - */ - public Drawable getDocumentIcon(Context context, String authority, String id, - String mimeType, int icon) { + private Drawable getDocumentIcon( + Context context, String authority, String id, String mimeType, int icon) { if (icon != 0) { return IconUtils.loadPackageIcon(context, authority, icon); } else { return IconUtils.loadMimeIcon(context, mimeType, authority, id, mMode); } } + + /** + * Returns a mime icon or package icon for a {@link DocumentInfo}. + */ + public Drawable getDocumentIcon(Context context, DocumentInfo doc) { + return getDocumentIcon( + context, doc.authority, doc.documentId, doc.mimeType, doc.icon); + } } diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java index 0a2960f8ffe0..5c1522811364 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java @@ -25,6 +25,7 @@ import static com.android.documentsui.model.DocumentInfo.getCursorString; import android.database.Cursor; import android.database.MergeCursor; +import android.net.Uri; import android.os.Bundle; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; @@ -39,7 +40,6 @@ import com.android.documentsui.dirlist.MultiSelectManager.Selection; import com.android.documentsui.model.DocumentInfo; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -389,15 +389,15 @@ public class Model { return mIsLoading; } - List<DocumentInfo> getDocuments(Selection items) { - final int size = (items != null) ? items.size() : 0; + List<DocumentInfo> getDocuments(Selection selection) { + final int size = (selection != null) ? selection.size() : 0; final List<DocumentInfo> docs = new ArrayList<>(size); - for (String modelId: items.getAll()) { + // NOTE: That as this now iterates over only final (non-provisional) selection. + for (String modelId: selection) { final Cursor cursor = getItem(modelId); if (cursor == null) { - Log.w(TAG, - "Skipping document. Unabled to obtain cursor for modelId: " + modelId); + Log.w(TAG, "Skipping document. Unabled to obtain cursor for modelId: " + modelId); continue; } docs.add(DocumentInfo.fromDirectoryCursor(cursor)); @@ -405,6 +405,11 @@ public class Model { return docs; } + public Uri getItemUri(String modelId) { + final Cursor cursor = getItem(modelId); + return DocumentInfo.getUri(cursor); + } + void addUpdateListener(UpdateListener listener) { mUpdateListeners.add(listener); } diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java index 497887c47a81..eeefac04e564 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -184,10 +185,12 @@ public final class MultiSelectManager { } /** - * Updates selection to include items in {@code selection}. + * Returns an unordered array of selected positions, including any + * provisional selection currently in effect. */ - public void updateSelection(Selection selection) { - setItemsSelected(selection.getAll(), true); + public void restoreSelection(Selection other) { + setItemsSelected(other.mSelection, true); + // NOTE: We intentionally don't restore provisional selection. It's provisional. } /** @@ -233,7 +236,10 @@ public final class MultiSelectManager { Selection oldSelection = getSelection(new Selection()); mSelection.clear(); - for (String id: oldSelection.getAll()) { + for (String id: oldSelection.mSelection) { + notifyItemStateChanged(id, false); + } + for (String id: oldSelection.mProvisionalSelection) { notifyItemStateChanged(id, false); } } @@ -600,7 +606,7 @@ public final class MultiSelectManager { * Object representing the current selection. Provides read only access * public access, and private write access. */ - public static final class Selection implements Parcelable { + public static final class Selection implements Iterable<String>, Parcelable { // This class tracks selected items by managing two sets: the saved selection, and the total // selection. Saved selections are those which have been completed by tapping an item or by @@ -640,19 +646,18 @@ public final class MultiSelectManager { } /** - * Returns an unordered array of selected positions, including any - * provisional selection currently in effect. + * Returns an {@link Iterator} that iterators over the selection, *excluding* + * any provisional selection. + * + * {@inheritDoc} */ - public List<String> getAll() { - ArrayList<String> selection = - new ArrayList<String>(mSelection.size() + mProvisionalSelection.size()); - selection.addAll(mSelection); - selection.addAll(mProvisionalSelection); - return selection; + @Override + public Iterator<String> iterator() { + return mSelection.iterator(); } /** - * @return size of the selection. + * @return size of the selection including both final and provisional selected items. */ public int size() { return mSelection.size() + mProvisionalSelection.size(); @@ -833,6 +838,7 @@ public final class MultiSelectManager { return 0; } + @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(mDirectoryKey); dest.writeStringList(new ArrayList<>(mSelection)); diff --git a/packages/DocumentsUI/src/com/android/documentsui/model/DocumentInfo.java b/packages/DocumentsUI/src/com/android/documentsui/model/DocumentInfo.java index 3a86a51b2d18..b54c9bb98ac7 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/model/DocumentInfo.java +++ b/packages/DocumentsUI/src/com/android/documentsui/model/DocumentInfo.java @@ -30,14 +30,16 @@ import android.support.annotation.VisibleForTesting; import com.android.documentsui.DocumentsApplication; import com.android.documentsui.RootCursorWrapper; -import libcore.io.IoUtils; - import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.net.ProtocolException; +import java.util.Arrays; import java.util.Objects; +import java.util.Set; + +import libcore.io.IoUtils; /** * Representation of a {@link Document}. @@ -263,10 +265,12 @@ public class DocumentInfo implements Durable, Parcelable { return (flags & Document.FLAG_VIRTUAL_DOCUMENT) != 0; } + @Override public int hashCode() { return derivedUri.hashCode() + mimeType.hashCode(); } + @Override public boolean equals(Object o) { if (o == null) { return false; @@ -323,4 +327,21 @@ public class DocumentInfo implements Durable, Parcelable { fnfe.initCause(t); throw fnfe; } + + public static Uri getUri(Cursor cursor) { + return DocumentsContract.buildDocumentUri( + getCursorString(cursor, RootCursorWrapper.COLUMN_AUTHORITY), + getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); + } + + public static void addMimeTypes(ContentResolver resolver, Uri uri, Set<String> mimeTypes) { + assert(uri != null); + if ("content".equals(uri.getScheme())) { + mimeTypes.add(resolver.getType(uri)); + final String[] streamTypes = resolver.getStreamTypes(uri, "*/*"); + if (streamTypes != null) { + mimeTypes.addAll(Arrays.asList(streamTypes)); + } + } + } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java new file mode 100644 index 000000000000..986ec793aaad --- /dev/null +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java @@ -0,0 +1,118 @@ +/* + * 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.net.Uri; +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import com.android.documentsui.ClipStorage.Writer; +import com.android.documentsui.dirlist.TestModel; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ClipStorageTest { + private static final List<Uri> TEST_URIS = createList( + "content://ham/fancy", + "content://poodle/monkey/giraffe"); + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private ClipStorage mStorage; + private TestModel mModel; + + @Before + public void setUp() { + File clipDir = ClipStorage.prepareStorage(folder.getRoot()); + mStorage = new ClipStorage(clipDir); + } + + @Test + public void testWritePrimary() throws Exception { + Writer writer = mStorage.createWriter(); + writeAll(TEST_URIS, writer); + } + + @Test + public void testRead() throws Exception { + Writer writer = mStorage.createWriter(); + writeAll(TEST_URIS, writer); + long tag = mStorage.savePrimary(); + List<Uri> uris = mStorage.read(tag); + assertEquals(TEST_URIS, uris); + } + + @Test + public void testDelete() throws Exception { + Writer writer = mStorage.createWriter(); + writeAll(TEST_URIS, writer); + long tag = mStorage.savePrimary(); + mStorage.delete(tag); + try { + mStorage.read(tag); + } catch (IOException expected) {} + } + + @Test + public void testPrepareStorage_CreatesDir() throws Exception { + File clipDir = ClipStorage.prepareStorage(folder.getRoot()); + assertTrue(clipDir.exists()); + assertTrue(clipDir.isDirectory()); + assertFalse(clipDir.equals(folder.getRoot())); + } + + @Test + public void testPrepareStorage_DeletesPreviousClipFiles() throws Exception { + File clipDir = ClipStorage.prepareStorage(folder.getRoot()); + new File(clipDir, "somefakefile.poodles").createNewFile(); + new File(clipDir, "yodles.yam").createNewFile(); + + assertEquals(2, clipDir.listFiles().length); + clipDir = ClipStorage.prepareStorage(folder.getRoot()); + assertEquals(0, clipDir.listFiles().length); + } + + private static void writeAll(List<Uri> uris, Writer writer) throws IOException { + for (Uri uri : uris) { + writer.write(uri); + } + } + + private static List<Uri> createList(String... values) { + List<Uri> uris = new ArrayList<>(values.length); + for (int i = 0; i < values.length; i++) { + uris.add(i, Uri.parse(values[i])); + } + return uris; + } +} |