--- /dev/null
+/*
+ * 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;
+ }
+}
package com.android.documentsui;
import android.content.ClipData;
+import android.content.ClipDescription;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
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;
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
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);
}
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;
}
/**
- * 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);
/**
* 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);
* @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);
}
* @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;
*
* @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;
}
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) {
mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4);
- mClipper = new DocumentClipper(this);
+ mClipper = createClipper(this.getApplicationContext());
final IntentFilter packageFilter = new IntentFilter();
packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
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);
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
}
@Override
- protected Void run(Uri... params) {
+ public Void run(Uri... params) {
final Uri uri = params[0];
final RootsCache rootsCache = DocumentsApplication.getRootsCache(mOwner);
}
@Override
- protected void finish(Void result) {
+ public void finish(Void result) {
mOwner.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
}
}
/**
* 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;
--- /dev/null
+/*
+ * 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();
+ }
+}
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;
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;
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;
.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.
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() {
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() {
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
}
}
+ 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);
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.
--- /dev/null
+/*
+ * 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);
+ }
+}
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;
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);
+ }
}
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;
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;
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));
return docs;
}
+ public Uri getItemUri(String modelId) {
+ final Cursor cursor = getItem(modelId);
+ return DocumentInfo.getUri(cursor);
+ }
+
void addUpdateListener(UpdateListener listener) {
mUpdateListeners.add(listener);
}
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;
}
/**
- * 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.
}
/**
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);
}
}
* 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
}
/**
- * 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();
return 0;
}
+ @Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mDirectoryKey);
dest.writeStringList(new ArrayList<>(mSelection));
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}.
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;
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));
+ }
+ }
+ }
}
--- /dev/null
+/*
+ * 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;
+ }
+}