diff options
27 files changed, 1212 insertions, 649 deletions
diff --git a/packages/DocumentsUI/AndroidManifest.xml b/packages/DocumentsUI/AndroidManifest.xml index 69912ab12098..69bcbc2d9962 100644 --- a/packages/DocumentsUI/AndroidManifest.xml +++ b/packages/DocumentsUI/AndroidManifest.xml @@ -113,9 +113,13 @@ </intent-filter> </receiver> + <!-- Run FileOperationService in a separate process so that we can use FileLock class to + wait until jumbo clip is done writing to disk before reading it. See ClipStorage for + details. --> <service android:name=".services.FileOperationService" - android:exported="false"> + android:exported="false" + android:process=":com.android.documentsui.services"> </service> </application> </manifest> diff --git a/packages/DocumentsUI/src/com/android/documentsui/ClipDetails.java b/packages/DocumentsUI/src/com/android/documentsui/ClipDetails.java new file mode 100644 index 000000000000..6cd035376e1f --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/ClipDetails.java @@ -0,0 +1,343 @@ +/* + * 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 com.android.documentsui.DocumentClipper.OP_JUMBO_SELECTION_SIZE; +import static com.android.documentsui.DocumentClipper.OP_JUMBO_SELECTION_TAG; +import static com.android.documentsui.DocumentClipper.OP_TYPE_KEY; +import static com.android.documentsui.DocumentClipper.SRC_PARENT_KEY; + +import android.annotation.CallSuper; +import android.annotation.Nullable; +import android.content.ClipData; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.PersistableBundle; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import com.android.documentsui.dirlist.MultiSelectManager.Selection; +import com.android.documentsui.services.FileOperationService; +import com.android.documentsui.services.FileOperationService.OpType; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +/** + * ClipDetails is a parcelable project providing information of different type of file + * management operations like cut, move and copy. + * + * Under the hood it provides cross-process synchronization support such that its consumer doesn't + * need to explicitly synchronize its access. + */ +public abstract class ClipDetails implements Parcelable { + private final @OpType int mOpType; + + // This field is used only for moving and deleting. Currently it's not the case, + // but in the future those files may be from multiple different parents. In + // such case, this needs to be replaced with pairs of parent and child. + private final @Nullable Uri mSrcParent; + + private ClipDetails(ClipData clipData) { + PersistableBundle bundle = clipData.getDescription().getExtras(); + mOpType = bundle.getInt(OP_TYPE_KEY); + + String srcParentString = bundle.getString(SRC_PARENT_KEY); + mSrcParent = (srcParentString == null) ? null : Uri.parse(srcParentString); + + // Only copy doesn't need src parent + assert(mOpType == FileOperationService.OPERATION_COPY || mSrcParent != null); + } + + private ClipDetails(@OpType int opType, @Nullable Uri srcParent) { + mOpType = opType; + mSrcParent = srcParent; + + // Only copy doesn't need src parent + assert(mOpType == FileOperationService.OPERATION_COPY || mSrcParent != null); + } + + public @OpType int getOpType() { + return mOpType; + } + + public @Nullable Uri getSrcParent() { + return mSrcParent; + } + + public abstract int getItemCount(); + + /** + * Gets doc list from this clip detail. This may only be called once because it may read a file + * to get the list. + */ + public Iterable<Uri> getDocs(Context context) throws IOException { + ClipStorage storage = DocumentsApplication.getClipStorage(context); + + return getDocs(storage); + } + + @VisibleForTesting + abstract Iterable<Uri> getDocs(ClipStorage storage) throws IOException; + + public void dispose(Context context) { + ClipStorage storage = DocumentsApplication.getClipStorage(context); + dispose(storage); + } + + @VisibleForTesting + void dispose(ClipStorage storage) {} + + private ClipDetails(Parcel in) { + mOpType = in.readInt(); + mSrcParent = in.readParcelable(ClassLoader.getSystemClassLoader()); + } + + @Override + public int describeContents() { + return 0; + } + + @CallSuper + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mOpType); + dest.writeParcelable(mSrcParent, 0); + } + + private void appendTo(StringBuilder builder) { + builder.append("opType=").append(mOpType); + builder.append(", srcParent=").append(mSrcParent); + } + + public static ClipDetails createClipDetails(ClipData clipData) { + ClipDetails details; + PersistableBundle bundle = clipData.getDescription().getExtras(); + if (bundle.containsKey(OP_JUMBO_SELECTION_TAG)) { + details = new JumboClipDetails(clipData); + } else { + details = new StandardClipDetails(clipData); + } + + return details; + } + + public static ClipDetails createClipDetails(@OpType int opType, @Nullable Uri srcParent, + Selection selection, Function<String, Uri> uriBuilder, Context context) { + ClipStorage storage = DocumentsApplication.getClipStorage(context); + + List<Uri> uris = new ArrayList<>(selection.size()); + for (String id : selection) { + uris.add(uriBuilder.apply(id)); + } + + return createClipDetails(opType, srcParent, uris, storage); + } + + @VisibleForTesting + static ClipDetails createClipDetails(@OpType int opType, @Nullable Uri srcParent, + List<Uri> uris, ClipStorage storage) { + ClipDetails details = (uris.size() > Shared.MAX_DOCS_IN_INTENT) + ? new JumboClipDetails(opType, srcParent, uris, storage) + : new StandardClipDetails(opType, srcParent, uris); + + return details; + } + + private static class JumboClipDetails extends ClipDetails { + private static final String TAG = "JumboClipDetails"; + + private final long mSelectionTag; + private final int mSelectionSize; + + private transient ClipStorage.Reader mReader; + + private JumboClipDetails(ClipData clipData) { + super(clipData); + + PersistableBundle bundle = clipData.getDescription().getExtras(); + mSelectionTag = bundle.getLong(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG); + assert(mSelectionTag != ClipStorage.NO_SELECTION_TAG); + + mSelectionSize = bundle.getInt(OP_JUMBO_SELECTION_SIZE); + assert(mSelectionSize > Shared.MAX_DOCS_IN_INTENT); + } + + private JumboClipDetails(@OpType int opType, @Nullable Uri srcParent, Collection<Uri> uris, + ClipStorage storage) { + super(opType, srcParent); + + mSelectionTag = storage.createTag(); + new ClipStorage.PersistTask(storage, uris, mSelectionTag).execute(); + mSelectionSize = uris.size(); + } + + @Override + public int getItemCount() { + return mSelectionSize; + } + + @Override + public Iterable<Uri> getDocs(ClipStorage storage) throws IOException { + if (mReader != null) { + throw new IllegalStateException( + "JumboClipDetails#getDocs() can only be called once."); + } + + mReader = storage.createReader(mSelectionTag); + + return mReader; + } + + @Override + void dispose(ClipStorage storage) { + if (mReader != null) { + try { + mReader.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close the reader.", e); + } + } + try { + storage.delete(mSelectionTag); + } catch(IOException e) { + Log.w(TAG, "Failed to delete clip with tag: " + mSelectionTag + ".", e); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("JumboClipDetails{"); + super.appendTo(builder); + builder.append(", selectionTag=").append(mSelectionTag); + builder.append(", selectionSize=").append(mSelectionSize); + builder.append("}"); + return builder.toString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + + dest.writeLong(mSelectionTag); + dest.writeInt(mSelectionSize); + } + + private JumboClipDetails(Parcel in) { + super(in); + + mSelectionTag = in.readLong(); + mSelectionSize = in.readInt(); + } + + public static final Parcelable.Creator<JumboClipDetails> CREATOR = + new Parcelable.Creator<JumboClipDetails>() { + + @Override + public JumboClipDetails createFromParcel(Parcel source) { + return new JumboClipDetails(source); + } + + @Override + public JumboClipDetails[] newArray(int size) { + return new JumboClipDetails[size]; + } + }; + } + + @VisibleForTesting + public static class StandardClipDetails extends ClipDetails { + private final List<Uri> mDocs; + + private StandardClipDetails(ClipData clipData) { + super(clipData); + mDocs = listDocs(clipData); + } + + @VisibleForTesting + public StandardClipDetails(@OpType int opType, @Nullable Uri srcParent, List<Uri> docs) { + super(opType, srcParent); + + mDocs = docs; + } + + private List<Uri> listDocs(ClipData clipData) { + ArrayList<Uri> docs = new ArrayList<>(clipData.getItemCount()); + + for (int i = 0; i < clipData.getItemCount(); ++i) { + Uri uri = clipData.getItemAt(i).getUri(); + assert(uri != null); + docs.add(uri); + } + + return docs; + } + + @Override + public int getItemCount() { + return mDocs.size(); + } + + @Override + public Iterable<Uri> getDocs(ClipStorage storage) { + return mDocs; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("StandardClipDetails{"); + super.appendTo(builder); + builder.append(", ").append("docs=").append(mDocs.toString()); + builder.append("}"); + return builder.toString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + + dest.writeTypedList(mDocs); + } + + private StandardClipDetails(Parcel in) { + super(in); + + mDocs = in.createTypedArrayList(Uri.CREATOR); + } + + public static final Parcelable.Creator<StandardClipDetails> CREATOR = + new Parcelable.Creator<StandardClipDetails>() { + + @Override + public StandardClipDetails createFromParcel(Parcel source) { + return new StandardClipDetails(source); + } + + @Override + public StandardClipDetails[] newArray(int size) { + return new StandardClipDetails[size]; + } + }; + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java b/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java index 0167accc352d..5102718e8667 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java +++ b/packages/DocumentsUI/src/com/android/documentsui/ClipStorage.java @@ -17,16 +17,17 @@ package com.android.documentsui; import android.net.Uri; +import android.os.AsyncTask; import android.support.annotation.VisibleForTesting; +import android.util.Log; -import java.io.BufferedReader; import java.io.Closeable; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; -import java.io.FileReader; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.nio.channels.FileLock; +import java.util.Scanner; /** * Provides support for storing lists of documents identified by Uri. @@ -36,9 +37,10 @@ import java.util.List; */ public final class ClipStorage { - private static final String PRIMARY_SELECTION = "primary-selection.txt"; + private static final String TAG = "ClipStorage"; + private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes(); - private static final int NO_SELECTION_TAG = -1; + public static final long NO_SELECTION_TAG = -1; private final File mOutDir; @@ -51,46 +53,27 @@ public final class ClipStorage { } /** - * Returns a writer. Callers must... + * Creates a clip tag. * - * <li>synchronize on the {@link ClipStorage} instance while writing to this writer. - * <li>closed the write when finished. + * NOTE: this tag doesn't guarantee perfect uniqueness, but should work well unless user creates + * clips more than hundreds of times per second. */ - public Writer createWriter() throws IOException { - File primary = new File(mOutDir, PRIMARY_SELECTION); - return new Writer(new FileOutputStream(primary)); + public long createTag() { + return System.currentTimeMillis(); } /** - * Saves primary uri list to persistent storage. - * @return tag identifying the saved set. + * Returns a writer. Callers must close the writer when finished. */ - @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; + public Writer createWriter(long tag) throws IOException { + File file = toTagFile(tag); + return new Writer(file); } @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; + public Reader createReader(long tag) throws IOException { + File file = toTagFile(tag); + return new Reader(file); } @VisibleForTesting @@ -102,12 +85,87 @@ public final class ClipStorage { return new File(mOutDir, String.valueOf(tag)); } - public static final class Writer implements Closeable { + /** + * Provides initialization of the clip data storage directory. + */ + static File prepareStorage(File cacheDir) { + File clipDir = getClipDir(cacheDir); + clipDir.mkdir(); + + assert(clipDir.isDirectory()); + return clipDir; + } + + public static boolean hasDocList(long tag) { + return tag != NO_SELECTION_TAG; + } + + private static File getClipDir(File cacheDir) { + return new File(cacheDir, "clippings"); + } + + static final class Reader implements Iterable<Uri>, Closeable { + + private final Scanner mScanner; + private final FileLock mLock; + + private Reader(File file) throws IOException { + FileInputStream inStream = new FileInputStream(file); + + // Lock the file here so it won't pass this line until the corresponding writer is done + // writing. + mLock = inStream.getChannel().lock(0L, Long.MAX_VALUE, true); + + mScanner = new Scanner(inStream); + } + + @Override + public Iterator iterator() { + return new Iterator(mScanner); + } + + @Override + public void close() throws IOException { + if (mLock != null) { + mLock.release(); + } + + if (mScanner != null) { + mScanner.close(); + } + } + } + + private static final class Iterator implements java.util.Iterator { + private final Scanner mScanner; + + private Iterator(Scanner scanner) { + mScanner = scanner; + } + + @Override + public boolean hasNext() { + return mScanner.hasNextLine(); + } + + @Override + public Uri next() { + String line = mScanner.nextLine(); + return Uri.parse(line); + } + } + + private static final class Writer implements Closeable { private final FileOutputStream mOut; + private final FileLock mLock; + + private Writer(File file) throws IOException { + mOut = new FileOutputStream(file); - public Writer(FileOutputStream out) { - mOut = out; + // Lock the file here so copy tasks would wait until everything is flushed to disk + // before start to run. + mLock = mOut.getChannel().lock(); } public void write(Uri uri) throws IOException { @@ -117,20 +175,43 @@ public final class ClipStorage { @Override public void close() throws IOException { - mOut.close(); + if (mLock != null) { + mLock.release(); + } + + if (mOut != null) { + mOut.close(); + } } } /** - * Provides initialization and cleanup of the clip data storage directory. + * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}. */ - static File prepareStorage(File cacheDir) { - File clipDir = new File(cacheDir, "clippings"); - if (clipDir.exists()) { - Files.deleteRecursively(clipDir); + static final class PersistTask extends AsyncTask<Void, Void, Void> { + + private final ClipStorage mClipStorage; + private final Iterable<Uri> mUris; + private final long mTag; + + PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, long tag) { + mClipStorage = clipStorage; + mUris = uris; + mTag = tag; + } + + @Override + protected Void doInBackground(Void... params) { + try (ClipStorage.Writer writer = mClipStorage.createWriter(mTag)) { + for (Uri uri: mUris) { + assert(uri != null); + writer.write(uri); + } + } catch (IOException e) { + Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e); + } + + return null; } - 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 3d8ac2c936a6..4c103c42c2d5 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentClipper.java @@ -21,25 +21,23 @@ import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.Context; +import android.content.SharedPreferences; import android.net.Uri; -import android.os.AsyncTask; +import android.os.BaseBundle; import android.os.PersistableBundle; 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; 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.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -49,26 +47,49 @@ import java.util.function.Function; * ClipboardManager wrapper class providing higher level logical * support for dealing with Documents. */ -public final class DocumentClipper { +public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChangedListener { 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"; + + 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"; + + // Use shared preference to store last seen primary clip tag, so that we can delete the file + // when we realize primary clip has been changed when we're not running. + private static final String PREF_NAME = "DocumentClipperPref"; + private static final String LAST_PRIMARY_CLIP_TAG = "lastPrimaryClipTag"; private final Context mContext; private final ClipStorage mClipStorage; private final ClipboardManager mClipboard; + // Here we're tracking the last clipped tag ids so we can delete them later. + private long mLastDragClipTag = ClipStorage.NO_SELECTION_TAG; + private long mLastUnusedPrimaryClipTag = ClipStorage.NO_SELECTION_TAG; + + private final SharedPreferences mPref; + DocumentClipper(Context context, ClipStorage storage) { mContext = context; mClipStorage = storage; mClipboard = context.getSystemService(ClipboardManager.class); + + mClipboard.addPrimaryClipChangedListener(this); + + // Primary clips may be changed when we're not running, now it's time to clean up the + // remnant. + mPref = context.getSharedPreferences(PREF_NAME, 0); + mLastUnusedPrimaryClipTag = + mPref.getLong(LAST_PRIMARY_CLIP_TAG, ClipStorage.NO_SELECTION_TAG); + deleteLastUnusedPrimaryClip(); } public boolean hasItemsToPaste() { if (mClipboard.hasPrimaryClip()) { ClipData clipData = mClipboard.getPrimaryClip(); + int count = clipData.getItemCount(); if (count > 0) { for (int i = 0; i < count; ++i) { @@ -87,51 +108,27 @@ public final class DocumentClipper { return uri != null && DocumentsContract.isDocumentUri(mContext, uri); } - public ClipDetails getClipDetails(@Nullable ClipData clipData) { - if (clipData == null) { - return null; - } - - String srcParent = clipData.getDescription().getExtras().getString(SRC_PARENT_KEY); + /** + * Returns {@link ClipData} representing the selection, or null if selection is empty, + * or cannot be converted. + * + * This is specialized for drag and drop so that we know which file to delete if nobody accepts + * the drop. + */ + public @Nullable ClipData getClipDataForDrag( + Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { + ClipData data = getClipDataForDocuments(uriBuilder, selection, opType); - ClipDetails clipDetails = new ClipDetails( - clipData.getDescription().getExtras().getInt(OP_TYPE_KEY), - getDocumentsFromClipData(clipData), - createDocument((srcParent != null) ? Uri.parse(srcParent) : null)); + mLastDragClipTag = getTag(data); - return clipDetails; - } - - private List<DocumentInfo> getDocumentsFromClipData(ClipData clipData) { - assert(clipData != null); - - int count = clipData.getItemCount(); - if (count == 0) { - return Collections.EMPTY_LIST; - } - - final List<DocumentInfo> srcDocs = new ArrayList<>(); - - for (int i = 0; i < count; ++i) { - ClipData.Item item = clipData.getItemAt(i); - Uri itemUri = item.getUri(); - DocumentInfo docInfo = createDocument(itemUri); - if (docInfo != null) { - srcDocs.add(docInfo); - } else { - // This uri either doesn't exist, or is invalid. - Log.w(TAG, "Can't create document info from uri: " + itemUri); - } - } - - return srcDocs; + return data; } /** * Returns {@link ClipData} representing the selection, or null if selection is empty, * or cannot be converted. */ - public @Nullable ClipData getClipDataForDocuments( + private @Nullable ClipData getClipDataForDocuments( Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { assert(selection != null); @@ -153,6 +150,7 @@ public final class DocumentClipper { Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { assert(!selection.isEmpty()); + assert(selection.size() <= Shared.MAX_DOCS_IN_INTENT); final ContentResolver resolver = mContext.getContentResolver(); final ArrayList<ClipData.Item> clipItems = new ArrayList<>(); @@ -161,15 +159,11 @@ public final class DocumentClipper { 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++; + DocumentInfo.addMimeTypes(resolver, uri, clipTypes); + clipItems.add(new ClipData.Item(uri)); } ClipDescription description = new ClipDescription( @@ -181,46 +175,51 @@ public final class DocumentClipper { } /** - * Returns ClipData representing the list of docs, or null if docs is empty, - * or docs cannot be converted. + * Returns ClipData representing the list of docs */ private @Nullable ClipData createJumboClipData( Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) { assert(!selection.isEmpty()); + assert(selection.size() > Shared.MAX_DOCS_IN_INTENT); + + final List<Uri> uris = new ArrayList<>(selection.size()); + + final int capacity = Math.min(selection.size(), Shared.MAX_DOCS_IN_INTENT); + final ArrayList<ClipData.Item> clipItems = new ArrayList<>(capacity); + // Set up mime types for the first Shared.MAX_DOCS_IN_INTENT final ContentResolver resolver = mContext.getContentResolver(); - final ArrayList<ClipData.Item> clipItems = new ArrayList<>(); final Set<String> 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()); - 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; - } - } + // Creates a clip tag + long tag = mClipStorage.createTag(); + bundle.putLong(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); + // Persists clip items + new ClipStorage.PersistTask(mClipStorage, uris, tag).execute(); + return new ClipData(description, clipItems); } @@ -232,7 +231,7 @@ public final class DocumentClipper { getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY); assert(data != null); - mClipboard.setPrimaryClip(data); + setPrimaryClip(data); } /** @@ -250,20 +249,65 @@ public final class DocumentClipper { PersistableBundle bundle = data.getDescription().getExtras(); bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString()); + setPrimaryClip(data); + } + + private void setPrimaryClip(ClipData data) { + deleteLastPrimaryClip(); + + long tag = getTag(data); + setLastUnusedPrimaryClipTag(tag); + mClipboard.setPrimaryClip(data); } - private DocumentInfo createDocument(Uri uri) { - DocumentInfo doc = null; - if (isDocumentUri(uri)) { - ContentResolver resolver = mContext.getContentResolver(); - try { - doc = DocumentInfo.fromUri(resolver, uri); - } catch (Exception e) { - Log.e(TAG, e.getMessage()); - } + /** + * Sets this primary tag to both class variable and shared preference. + */ + private void setLastUnusedPrimaryClipTag(long tag) { + mLastUnusedPrimaryClipTag = tag; + mPref.edit().putLong(LAST_PRIMARY_CLIP_TAG, tag).commit(); + } + + /** + * This is a good chance for us to remove previous clip file for cut/copy because we know a new + * primary clip is set. + */ + @Override + public void onPrimaryClipChanged() { + deleteLastUnusedPrimaryClip(); + } + + private void deleteLastUnusedPrimaryClip() { + ClipData primary = mClipboard.getPrimaryClip(); + long primaryTag = getTag(primary); + + // onPrimaryClipChanged is also called after we call setPrimaryClip(), so make sure we don't + // delete the clip file we just created. + if (mLastUnusedPrimaryClipTag != primaryTag) { + deleteLastPrimaryClip(); + } + } + + private void deleteLastPrimaryClip() { + deleteClip(mLastUnusedPrimaryClipTag); + setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG); + } + + /** + * Deletes the last seen drag clip file. + */ + public void deleteDragClip() { + deleteClip(mLastDragClipTag); + mLastDragClipTag = ClipStorage.NO_SELECTION_TAG; + } + + private void deleteClip(long tag) { + try { + mClipStorage.delete(tag); + } catch (IOException e) { + Log.w(TAG, "Error deleting clip file with tag: " + tag, e); } - return doc; } /** @@ -279,6 +323,10 @@ public final class DocumentClipper { DocumentStack docStack, FileOperations.Callback callback) { + // The primary clip has been claimed by a file operation. It's now the operation's duty + // to make sure the clip file is deleted after use. + setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG); + copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback); } @@ -301,65 +349,28 @@ public final class DocumentClipper { return; } - new AsyncTask<Void, Void, ClipDetails>() { - - @Override - protected ClipDetails doInBackground(Void... params) { - return getClipDetails(clipData); - } + ClipDetails details = ClipDetails.createClipDetails(clipData); - @Override - protected void onPostExecute(ClipDetails clipDetails) { - if (clipDetails == null) { - Log.w(TAG, "Received null clipDetails. Ignoring."); - return; - } - - List<DocumentInfo> docs = clipDetails.docs; - @OpType int type = clipDetails.opType; - DocumentInfo srcParent = clipDetails.parent; - moveDocuments(docs, destination, docStack, type, srcParent, callback); - } - }.execute(); - } - - /** - * Moves {@code docs} from {@code srcParent} to {@code destination}. - * operationType can be copy or cut - * srcParent Must be non-null for move operations. - */ - private void moveDocuments( - List<DocumentInfo> docs, - DocumentInfo destination, - DocumentStack docStack, - @OpType int operationType, - DocumentInfo srcParent, - FileOperations.Callback callback) { - - RootInfo destRoot = docStack.root; - if (!canCopy(docs, destRoot, destination)) { - callback.onOperationResult(FileOperations.Callback.STATUS_REJECTED, operationType, 0); + if (!canCopy(destination)) { + callback.onOperationResult( + FileOperations.Callback.STATUS_REJECTED, details.getOpType(), 0); return; } - if (docs.isEmpty()) { - callback.onOperationResult(FileOperations.Callback.STATUS_ACCEPTED, operationType, 0); + if (details.getItemCount() == 0) { + callback.onOperationResult( + FileOperations.Callback.STATUS_ACCEPTED, details.getOpType(), 0); return; } DocumentStack dstStack = new DocumentStack(); dstStack.push(destination); dstStack.addAll(docStack); - switch (operationType) { - case FileOperationService.OPERATION_MOVE: - FileOperations.move(mContext, docs, srcParent, dstStack, callback); - break; - case FileOperationService.OPERATION_COPY: - FileOperations.copy(mContext, docs, dstStack, callback); - break; - default: - throw new UnsupportedOperationException("Unsupported operation: " + operationType); - } + + // Pass root here so that we can perform "download" root check when + dstStack.root = docStack.root; + + FileOperations.start(mContext, details, dstStack, callback); } /** @@ -370,32 +381,26 @@ public final class DocumentClipper { * * @return true if the list of files can be copied to destination. */ - private static boolean canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest) { + private static boolean canCopy(DocumentInfo dest) { if (dest == null || !dest.isDirectory() || !dest.isCreateSupported()) { return false; } - // Can't copy folders to downloads, because we don't show folders there. - if (root.isDownloads()) { - for (DocumentInfo docs : files) { - if (docs.isDirectory()) { - return false; - } - } - } - return true; } - public static class ClipDetails { - public final @OpType int opType; - public final List<DocumentInfo> docs; - public final @Nullable DocumentInfo parent; - - ClipDetails(@OpType int opType, List<DocumentInfo> docs, @Nullable DocumentInfo parent) { - this.opType = opType; - this.docs = docs; - this.parent = parent; + /** + * Obtains tag from {@link ClipData}. Returns {@link ClipStorage#NO_SELECTION_TAG} + * if it's not a jumbo clip. + */ + private static long getTag(@Nullable ClipData data) { + if (data == null) { + return ClipStorage.NO_SELECTION_TAG; } + + ClipDescription description = data.getDescription(); + BaseBundle bundle = description.getExtras(); + return bundle.getLong(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG); } + } diff --git a/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java b/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java index 2b2d1f40060b..3d3902d9968b 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentsApplication.java @@ -28,14 +28,13 @@ 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 ClipStorage mClipStorage; private DocumentClipper mClipper; public static RootsCache getRootsCache(Context context) { @@ -62,6 +61,10 @@ public class DocumentsApplication extends Application { return ((DocumentsApplication) context.getApplicationContext()).mClipper; } + public static ClipStorage getClipStorage(Context context) { + return ((DocumentsApplication) context.getApplicationContext()).mClipStorage; + } + @Override public void onCreate() { super.onCreate(); @@ -74,7 +77,8 @@ public class DocumentsApplication extends Application { mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4); - mClipper = createClipper(this.getApplicationContext()); + mClipStorage = new ClipStorage(ClipStorage.prepareStorage(getCacheDir())); + mClipper = new DocumentClipper(this, mClipStorage); final IntentFilter packageFilter = new IntentFilter(); packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); @@ -89,12 +93,6 @@ 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/Snackbars.java b/packages/DocumentsUI/src/com/android/documentsui/Snackbars.java index 4274efaed5d8..c3a82d779555 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/Snackbars.java +++ b/packages/DocumentsUI/src/com/android/documentsui/Snackbars.java @@ -24,6 +24,12 @@ import android.view.View; public final class Snackbars { private Snackbars() {} + public static final void showDocumentsClipped(Activity activity, int docCount) { + String msg = Shared.getQuantityString( + activity, R.plurals.clipboard_files_clipped, docCount); + Snackbars.makeSnackbar(activity, msg, Snackbar.LENGTH_SHORT).show(); + } + public static final void showMove(Activity activity, int docCount) { CharSequence message = Shared.getQuantityString(activity, R.plurals.move_begin, docCount); makeSnackbar(activity, message, Snackbar.LENGTH_SHORT).show(); diff --git a/packages/DocumentsUI/src/com/android/documentsui/State.java b/packages/DocumentsUI/src/com/android/documentsui/State.java index f239eb45552b..9cdf1a804ced 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/State.java +++ b/packages/DocumentsUI/src/com/android/documentsui/State.java @@ -25,7 +25,6 @@ import android.os.Parcelable; import android.util.Log; import android.util.SparseArray; -import com.android.documentsui.dirlist.MultiSelectManager.Selection; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.model.DurableUtils; @@ -123,9 +122,6 @@ public class State implements android.os.Parcelable { /** Instance state for every shown directory */ public HashMap<String, SparseArray<Parcelable>> dirState = new HashMap<>(); - /** Currently copying file */ - public List<DocumentInfo> selectedDocumentsForCopy = new ArrayList<>(); - /** Name of the package that started DocsUI */ public List<String> excludedAuthorities = new ArrayList<>(); @@ -199,7 +195,6 @@ public class State implements android.os.Parcelable { out.writeInt(external ? 1 : 0); DurableUtils.writeToParcel(out, stack); out.writeMap(dirState); - out.writeList(selectedDocumentsForCopy); out.writeList(excludedAuthorities); out.writeInt(openableOnly ? 1 : 0); out.writeInt(mStackTouched ? 1 : 0); @@ -229,7 +224,6 @@ public class State implements android.os.Parcelable { state.external = in.readInt() != 0; DurableUtils.readFromParcel(in, state.stack); in.readMap(state.dirState, loader); - in.readList(state.selectedDocumentsForCopy, loader); in.readList(state.excludedAuthorities, loader); state.openableOnly = in.readInt() != 0; state.mStackTouched = in.readInt() != 0; diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java deleted file mode 100644 index 3aefffbb2f13..000000000000 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ClipTask.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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/DirectoryDragListener.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java index e8361a1c9f5b..40ee14db2ce3 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java @@ -31,8 +31,16 @@ class DirectoryDragListener extends ItemDragListener<DirectoryFragment> { public boolean onDrag(View v, DragEvent event) { final boolean result = super.onDrag(v, event); - if (event.getAction() == DragEvent.ACTION_DRAG_ENDED && event.getResult()) { - mDragHost.clearSelection(); + if (event.getAction() == DragEvent.ACTION_DRAG_ENDED) { + // getResult() is true if drag was accepted + if (event.getResult()) { + mDragHost.clearSelection(); + } else { + // When drag starts we might write a new clip file to disk. + // No drop event happens, remove clip file here. This may be called multiple times, + // but it should be OK because deletion is idempotent and cheap. + mDragHost.deleteDragClipFile(); + } } return result; diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java index 6fcf24ce647e..afdcdf1cb228 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -72,10 +72,10 @@ import android.widget.TextView; import android.widget.Toolbar; import com.android.documentsui.BaseActivity; +import com.android.documentsui.ClipDetails; import com.android.documentsui.DirectoryLoader; import com.android.documentsui.DirectoryResult; import com.android.documentsui.DocumentClipper; -import com.android.documentsui.DocumentClipper.ClipDetails; import com.android.documentsui.DocumentsActivity; import com.android.documentsui.DocumentsApplication; import com.android.documentsui.Events; @@ -96,7 +96,6 @@ import com.android.documentsui.State; import com.android.documentsui.State.ViewMode; 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; import com.android.documentsui.services.FileOperationService.OpType; @@ -171,6 +170,9 @@ public class DirectoryFragment extends Fragment // Note, we use !null to indicate that selection was restored (from rotation). // So don't fiddle with this field unless you've got the bigger picture in mind. private @Nullable Selection mRestoredSelection = null; + // Here we save the clip details of moveTo/copyTo actions when picker shows up. + // This will be written to saved instance. + private @Nullable ClipDetails mDetailsForCopy; private boolean mSearchMode = false; private @Nullable BandController mBandController; @@ -200,6 +202,9 @@ public class DirectoryFragment extends Fragment case FileOperationService.OPERATION_COPY: Snackbars.showCopy(getActivity(), docCount); break; + case FileOperationService.OPERATION_DELETE: + // We don't show anything for deletion. + break; default: throw new UnsupportedOperationException("Unsupported Operation: " + opType); } @@ -264,6 +269,7 @@ public class DirectoryFragment extends Fragment mQuery = args.getString(Shared.EXTRA_QUERY); mType = args.getInt(Shared.EXTRA_TYPE); mSearchMode = args.getBoolean(Shared.EXTRA_SEARCH_MODE); + mDetailsForCopy = args.getParcelable(FileOperationService.EXTRA_CLIP_DETAILS); // Restore any selection we may have squirreled away in retained state. @Nullable RetainedState retained = getBaseActivity().getRetainedState(); @@ -353,6 +359,7 @@ public class DirectoryFragment extends Fragment outState.putParcelable(Shared.EXTRA_DOC, mDocument); outState.putString(Shared.EXTRA_QUERY, mQuery); outState.putBoolean(Shared.EXTRA_SEARCH_MODE, mSearchMode); + outState.putParcelable(FileOperationService.EXTRA_CLIP_DETAILS, mDetailsForCopy); } @Override @@ -392,22 +399,21 @@ public class DirectoryFragment extends Fragment } private void handleCopyResult(int resultCode, Intent data) { + + ClipDetails details = mDetailsForCopy; + mDetailsForCopy = null; + if (resultCode == Activity.RESULT_CANCELED || data == null) { // User pressed the back button or otherwise cancelled the destination pick. Don't // proceed with the copy. + details.dispose(getContext()); return; } - @OpType int operationType = data.getIntExtra( - FileOperationService.EXTRA_OPERATION, - FileOperationService.OPERATION_COPY); - FileOperations.start( getContext(), - getDisplayState().selectedDocumentsForCopy, - getDisplayState().stack.peek(), - (DocumentStack) data.getParcelableExtra(Shared.EXTRA_STACK), - operationType, + details, + data.getParcelableExtra(Shared.EXTRA_STACK), mFileOpCallback); } @@ -984,8 +990,14 @@ public class DirectoryFragment extends Fragment Log.w(TAG, "Action mode is null before deleting documents."); } - FileOperations.delete( - getActivity(), docs, srcParent, getDisplayState().stack); + ClipDetails details = ClipDetails.createClipDetails( + FileOperationService.OPERATION_DELETE, + srcParent.derivedUri, + selected, + mModel::getItemUri, + getContext()); + FileOperations.start(getActivity(), details, + getDisplayState().stack, mFileOpCallback); } }) .setNegativeButton(android.R.string.cancel, null) @@ -1009,6 +1021,9 @@ public class DirectoryFragment extends Fragment getActivity(), DocumentsActivity.class); + Uri srcParent = getDisplayState().stack.peek().derivedUri; + mDetailsForCopy = ClipDetails.createClipDetails( + mode, srcParent, selected, mModel::getItemUri, getContext()); // Relay any config overrides bits present in the original intent. Intent original = getActivity().getIntent(); @@ -1028,15 +1043,11 @@ public class DirectoryFragment extends Fragment new GetDocumentsTask() { @Override void onDocumentsReady(List<DocumentInfo> docs) { - // TODO: Can this move to Fragment bundle state? - getDisplayState().selectedDocumentsForCopy = docs; - // Determine if there is a directory in the set of documents // to be copied? Why? Directory creation isn't supported by some roots // (like Downloads). This informs DocumentsActivity (the "picker") // to restrict available roots to just those with support. intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs)); - intent.putExtra(FileOperationService.EXTRA_OPERATION, mode); // This just identifies the type of request...we'll check it // when we reveive a response. @@ -1164,13 +1175,9 @@ public class DirectoryFragment extends Fragment } mSelectionManager.clearSelection(); - // Clips the docs in the background, then displays a message - new ClipTask( - getActivity(), - () -> { - mClipper.clipDocumentsForCopy(mModel::getItemUri, selection); - }, - selection.size()).execute(); + mClipper.clipDocumentsForCopy(mModel::getItemUri, selection); + + Snackbars.showDocumentsClipped(getActivity(), selection.size()); } public void cutSelectedToClipboard() { @@ -1182,16 +1189,9 @@ public class DirectoryFragment extends Fragment } 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(); + mClipper.clipDocumentsForCut(mModel::getItemUri, selection, getDisplayState().stack.peek()); + + Snackbars.showDocumentsClipped(getActivity(), selection.size()); } public void pasteFromClipboard() { @@ -1273,15 +1273,19 @@ public class DirectoryFragment extends Fragment activity.setRootsDrawerOpen(false); } - public boolean handleDropEvent(View v, DragEvent event) { + void deleteDragClipFile() { + mClipper.deleteDragClip(); + } + + boolean handleDropEvent(View v, DragEvent event) { BaseActivity activity = (BaseActivity) getActivity(); activity.setRootsDrawerOpen(false); ClipData clipData = event.getClipData(); assert (clipData != null); - ClipDetails clipDetails = mClipper.getClipDetails(clipData); - assert(clipDetails.opType == FileOperationService.OPERATION_COPY); + assert(ClipDetails.createClipDetails(clipData).getOpType() + == FileOperationService.OPERATION_COPY); // Don't copy from the cwd into the cwd. Note: this currently doesn't work for // multi-window drag, because localState isn't carried over from one process to @@ -1478,7 +1482,7 @@ public class DirectoryFragment extends Fragment // 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( + mClipper.getClipDataForDrag( mModel::getItemUri, selection, FileOperationService.OPERATION_COPY), diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java index cde9e92141ad..fac8667aea8f 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java @@ -29,13 +29,13 @@ import static com.android.documentsui.model.DocumentInfo.getCursorString; import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION; import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST; -import static com.android.documentsui.services.FileOperationService.OPERATION_COPY; import android.annotation.StringRes; import android.app.Notification; import android.app.Notification.Builder; import android.app.PendingIntent; import android.content.ContentProviderClient; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.res.AssetFileDescriptor; @@ -50,11 +50,12 @@ import android.text.format.DateUtils; import android.util.Log; import android.webkit.MimeTypeMap; +import com.android.documentsui.ClipDetails; import com.android.documentsui.Metrics; import com.android.documentsui.R; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; -import com.android.documentsui.services.FileOperationService.OpType; +import com.android.documentsui.model.RootInfo; import libcore.io.IoUtils; @@ -83,30 +84,18 @@ class CopyJob extends Job { private long mRemainingTime; /** - * Copies files to a destination identified by {@code destination}. * @see @link {@link Job} constructor for most param descriptions. * - * @param srcs List of files to be copied. + * @param details clip details containing source file list */ CopyJob(Context service, Context appContext, Listener listener, - String id, DocumentStack stack, List<DocumentInfo> srcs) { - super(service, appContext, listener, OPERATION_COPY, id, stack); + String id, DocumentStack destination, ClipDetails details) { + super(service, appContext, listener, id, destination, details); - assert(!srcs.isEmpty()); - this.mSrcs = srcs; - } + assert(details.getItemCount() > 0); - /** - * @see @link {@link Job} constructor for most param descriptions. - * - * @param srcs List of files to be copied. - */ - CopyJob(Context service, Context appContext, Listener listener, - @OpType int opType, String id, DocumentStack destination, List<DocumentInfo> srcs) { - super(service, appContext, listener, opType, id, destination); - - assert(!srcs.isEmpty()); - this.mSrcs = srcs; + // delay the initialization of it to setUp() because it may be IO extensive. + mSrcs = new ArrayList<>(details.getItemCount()); } @Override @@ -167,7 +156,7 @@ class CopyJob extends Job { // mBytesCopied is modified in worker thread, but this method is called in monitor thread, // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent. final long bytesCopied = mBytesCopied; - final long sampleDuration = elapsedTime - mSampleTime; + final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; if (mSpeed == 0) { mSpeed = sampleSpeed; @@ -215,8 +204,18 @@ class CopyJob extends Job { } @Override - void start() { - mStartTime = elapsedRealtime(); + boolean setUp() { + + try { + buildDocumentList(); + } catch (ResourceException e) { + Log.e(TAG, "Failed to get the list of docs.", e); + return false; + } + + if (isCanceled()) { + return false; + } try { mBatchSize = calculateSize(mSrcs); @@ -225,6 +224,12 @@ class CopyJob extends Job { mBatchSize = -1; } + return true; + } + + @Override + void start() { + mStartTime = elapsedRealtime(); DocumentInfo srcInfo; DocumentInfo dstInfo = stack.peek(); for (int i = 0; i < mSrcs.size() && !isCanceled(); ++i) { @@ -249,6 +254,33 @@ class CopyJob extends Job { Metrics.logFileOperation(service, operationType, mSrcs, dstInfo); } + private void buildDocumentList() throws ResourceException { + try { + final ContentResolver resolver = appContext.getContentResolver(); + final Iterable<Uri> uris = details.getDocs(appContext); + for (Uri uri : uris) { + DocumentInfo doc = DocumentInfo.fromUri(resolver, uri); + if (canCopy(doc, stack.root)) { + mSrcs.add(doc); + } else { + onFileFailed(doc); + } + + if (isCanceled()) { + return; + } + } + } catch(IOException e) { + failedFileCount += details.getItemCount(); + throw new ResourceException("Failed to open the list of docs to copy.", e); + } + } + + private static boolean canCopy(DocumentInfo doc, RootInfo root) { + // Can't copy folders to downloads, because we don't show folders there. + return !root.isDownloads() || !doc.isDirectory(); + } + @Override boolean hasWarnings() { return !convertedFiles.isEmpty(); @@ -553,6 +585,10 @@ class CopyJob extends Job { } else { result += src.size; } + + if (isCanceled()) { + return result; + } } return result; } @@ -562,7 +598,7 @@ class CopyJob extends Job { * * @throws ResourceException */ - private static long calculateFileSizesRecursively( + private long calculateFileSizesRecursively( ContentProviderClient client, Uri uri) throws ResourceException { final String authority = uri.getAuthority(); final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri)); @@ -576,7 +612,7 @@ class CopyJob extends Job { Cursor cursor = null; try { cursor = client.query(queryUri, queryColumns, null, null, null); - while (cursor.moveToNext()) { + while (cursor.moveToNext() && !isCanceled()) { if (Document.MIME_TYPE_DIR.equals( getCursorString(cursor, Document.COLUMN_MIME_TYPE))) { // Recurse into directories. @@ -623,7 +659,7 @@ class CopyJob extends Job { .append("CopyJob") .append("{") .append("id=" + id) - .append(", srcs=" + mSrcs) + .append(", details=" + details) .append(", destination=" + stack) .append("}") .toString(); diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java index 8e27d6aad6d5..f5bc85e49a39 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java @@ -17,25 +17,27 @@ package com.android.documentsui.services; import static com.android.documentsui.Shared.DEBUG; -import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE; import android.app.Notification; import android.app.Notification.Builder; +import android.content.ContentResolver; import android.content.Context; +import android.net.Uri; import android.util.Log; +import com.android.documentsui.ClipDetails; import com.android.documentsui.Metrics; import com.android.documentsui.R; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; final class DeleteJob extends Job { private static final String TAG = "DeleteJob"; - private List<DocumentInfo> mSrcs; - final DocumentInfo mSrcParent; private volatile int mDocsProcessed = 0; @@ -46,14 +48,11 @@ final class DeleteJob extends Job { * * @see @link {@link Job} constructor for most param descriptions. * - * @param srcs List of files to delete. - * @param srcParent Parent of all source files. + * @param details details that contains files to be deleted and their parent */ DeleteJob(Context service, Context appContext, Listener listener, - String id, DocumentStack stack, List<DocumentInfo> srcs, DocumentInfo srcParent) { - super(service, appContext, listener, OPERATION_DELETE, id, stack); - this.mSrcs = srcs; - this.mSrcParent = srcParent; + String id, DocumentStack stack, ClipDetails details) { + super(service, appContext, listener, id, stack, details); } @Override @@ -72,9 +71,9 @@ final class DeleteJob extends Job { @Override public Notification getProgressNotification() { - mProgressBuilder.setProgress(mSrcs.size(), mDocsProcessed, false); + mProgressBuilder.setProgress(details.getItemCount(), mDocsProcessed, false); String format = service.getString(R.string.delete_progress); - mProgressBuilder.setSubText(String.format(format, mDocsProcessed, mSrcs.size())); + mProgressBuilder.setSubText(String.format(format, mDocsProcessed, details.getItemCount())); mProgressBuilder.setContentText(null); @@ -94,23 +93,37 @@ final class DeleteJob extends Job { @Override void start() { - for (DocumentInfo doc : mSrcs) { - if (DEBUG) Log.d(TAG, "Deleting document @ " + doc.derivedUri); - try { - deleteDocument(doc, mSrcParent); - - if (isCanceled()) { - // Canceled, dump the rest of the work. Deleted docs are not recoverable. - return; + try { + final List<DocumentInfo> srcs = new ArrayList<>(details.getItemCount()); + + final Iterable<Uri> uris = details.getDocs(appContext); + + final ContentResolver resolver = appContext.getContentResolver(); + final DocumentInfo srcParent = DocumentInfo.fromUri(resolver, details.getSrcParent()); + for (Uri uri : uris) { + DocumentInfo doc = DocumentInfo.fromUri(resolver, uri); + srcs.add(doc); + + if (DEBUG) Log.d(TAG, "Deleting document @ " + doc.derivedUri); + try { + deleteDocument(doc, srcParent); + + if (isCanceled()) { + // Canceled, dump the rest of the work. Deleted docs are not recoverable. + return; + } + } catch (ResourceException e) { + Log.e(TAG, "Failed to delete document @ " + doc.derivedUri, e); + onFileFailed(doc); } - } catch (ResourceException e) { - Log.e(TAG, "Failed to delete document @ " + doc.derivedUri, e); - onFileFailed(doc); - } - ++mDocsProcessed; + ++mDocsProcessed; + } + Metrics.logFileOperation(service, operationType, srcs, null); + } catch(IOException e) { + Log.e(TAG, "Failed to get list of docs or parent source.", e); + failedFileCount += details.getItemCount(); } - Metrics.logFileOperation(service, operationType, mSrcs, null); } @Override @@ -119,8 +132,7 @@ final class DeleteJob extends Job { .append("DeleteJob") .append("{") .append("id=" + id) - .append(", srcs=" + mSrcs) - .append(", srcParent=" + mSrcParent) + .append(", details=" + details) .append(", location=" + stack) .append("}") .toString(); diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java index a3bff90d7b7d..fec005094371 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java @@ -29,8 +29,8 @@ import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.util.Log; +import com.android.documentsui.ClipDetails; import com.android.documentsui.Shared; -import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.services.Job.Factory; @@ -58,13 +58,10 @@ public class FileOperationService extends Service implements Job.Listener { public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID"; public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION"; public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; - public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; + public static final String EXTRA_CLIP_DETAILS = "com.android.documentsui.SRC_CLIP_DETAIL"; public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE"; - // This extra is used only for moving and deleting. Currently it's not the case, - // but in the future those files may be from multiple different parents. In - // such case, this needs to be replaced with pairs of parent and child. - public static final String EXTRA_SRC_PARENT = "com.android.documentsui.SRC_PARENT"; + public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; @IntDef(flag = true, value = { OPERATION_UNKNOWN, @@ -145,6 +142,7 @@ public class FileOperationService extends Service implements Job.Listener { executor = null; deletionExecutor = null; handler = null; + if (DEBUG) Log.d(TAG, "Destroyed."); } @@ -154,35 +152,33 @@ public class FileOperationService extends Service implements Job.Listener { // checkArgument(flags == 0); // retry and redeliver are not supported. String jobId = intent.getStringExtra(EXTRA_JOB_ID); - @OpType int operationType = intent.getIntExtra(EXTRA_OPERATION, OPERATION_UNKNOWN); assert(jobId != null); + if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId); + if (intent.hasExtra(EXTRA_CANCEL)) { handleCancel(intent); } else { - assert(operationType != OPERATION_UNKNOWN); - handleOperation(intent, serviceId, jobId, operationType); + ClipDetails details = intent.getParcelableExtra(EXTRA_CLIP_DETAILS); + assert(details.getOpType() != OPERATION_UNKNOWN); + handleOperation(intent, jobId, details); } - return START_NOT_STICKY; - } - - private void handleOperation(Intent intent, int serviceId, String jobId, int operationType) { - if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId); - // Track the service supplied id so we can stop the service once we're out of work to do. mLastServiceId = serviceId; + return START_NOT_STICKY; + } + + private void handleOperation(Intent intent, String jobId, ClipDetails details) { synchronized (mRunning) { if (mWakeLock == null) { mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); } - List<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST); - DocumentInfo srcParent = intent.getParcelableExtra(EXTRA_SRC_PARENT); DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK); - Job job = createJob(operationType, jobId, srcs, srcParent, stack); + Job job = createJob(jobId, details, stack); if (job == null) { return; @@ -192,7 +188,7 @@ public class FileOperationService extends Service implements Job.Listener { assert (job != null); if (DEBUG) Log.d(TAG, "Scheduling job " + job.id + "."); - Future<?> future = getExecutorService(operationType).submit(job); + Future<?> future = getExecutorService(details.getOpType()).submit(job); mRunning.put(jobId, new JobRecord(job, future)); } } @@ -236,32 +232,26 @@ public class FileOperationService extends Service implements Job.Listener { */ @GuardedBy("mRunning") private @Nullable Job createJob( - @OpType int operationType, String id, List<DocumentInfo> srcs, DocumentInfo srcParent, - DocumentStack stack) { + String id, ClipDetails details, DocumentStack stack) { - if (srcs.isEmpty()) { - Log.w(TAG, "Ignoring job request with empty srcs list. Id: " + id); - return null; - } + assert(details.getItemCount() > 0); if (mRunning.containsKey(id)) { Log.w(TAG, "Duplicate job id: " + id - + ". Ignoring job request for srcs: " + srcs + ", stack: " + stack + "."); + + ". Ignoring job request for details: " + details + ", stack: " + stack + "."); return null; } - switch (operationType) { + switch (details.getOpType()) { case OPERATION_COPY: return jobFactory.createCopy( - this, getApplicationContext(), this, id, stack, srcs); + this, getApplicationContext(), this, id, stack, details); case OPERATION_MOVE: return jobFactory.createMove( - this, getApplicationContext(), this, id, stack, srcs, - srcParent); + this, getApplicationContext(), this, id, stack, details); case OPERATION_DELETE: return jobFactory.createDelete( - this, getApplicationContext(), this, id, stack, srcs, - srcParent); + this, getApplicationContext(), this, id, stack, details); default: throw new UnsupportedOperationException(); } @@ -341,7 +331,7 @@ public class FileOperationService extends Service implements Job.Listener { mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS); if (job.hasFailures()) { - Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + "."); + Log.e(TAG, "Job failed on files: " + job.failedFileCount + "."); mNotificationManager.notify( job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification()); } @@ -376,7 +366,6 @@ public class FileOperationService extends Service implements Job.Listener { * we poll states of jobs. */ private static final class JobMonitor implements Runnable { - private static final long INITIAL_PROGRESS_DELAY_MILLIS = 10L; private static final long PROGRESS_INTERVAL_MILLIS = 500L; private final Job mJob; @@ -390,8 +379,7 @@ public class FileOperationService extends Service implements Job.Listener { } private void start() { - // Delay the first update to avoid dividing by 0 when calculate speed - mHandler.postDelayed(this, INITIAL_PROGRESS_DELAY_MILLIS); + mHandler.post(this); } @Override @@ -402,8 +390,11 @@ public class FileOperationService extends Service implements Job.Listener { return; } - mNotificationManager.notify( - mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification()); + // Only job in set up state has progress bar + if (mJob.getState() == Job.STATE_SET_UP) { + mNotificationManager.notify( + mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification()); + } mHandler.postDelayed(this, PROGRESS_INTERVAL_MILLIS); } diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java index 3de814526822..034c0d7c30f1 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperations.java @@ -17,17 +17,12 @@ package com.android.documentsui.services; import static android.os.SystemClock.elapsedRealtime; + import static com.android.documentsui.Shared.DEBUG; import static com.android.documentsui.Shared.EXTRA_STACK; -import static com.android.documentsui.Shared.asArrayList; import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL; import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID; -import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION; -import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST; -import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_PARENT; -import static com.android.documentsui.services.FileOperationService.OPERATION_COPY; -import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE; -import static com.android.documentsui.services.FileOperationService.OPERATION_MOVE; +import static com.android.documentsui.services.FileOperationService.EXTRA_CLIP_DETAILS; import android.annotation.IntDef; import android.app.Activity; @@ -37,13 +32,12 @@ import android.os.Parcelable; import android.support.annotation.VisibleForTesting; import android.util.Log; -import com.android.documentsui.model.DocumentInfo; +import com.android.documentsui.ClipDetails; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.services.FileOperationService.OpType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.util.List; /** * Helper functions for starting various file operations. @@ -63,41 +57,20 @@ public final class FileOperations { /** * Tries to start the activity. Returns the job id. */ - public static String start(Context context, List<DocumentInfo> srcDocs, DocumentStack stack, - @OpType int operationType, Callback callback) { + public static String start(Context context, ClipDetails details, + DocumentStack stack, Callback callback) { if (DEBUG) Log.d(TAG, "Handling generic 'start' call."); - switch (operationType) { - case OPERATION_COPY: - return FileOperations.copy(context, srcDocs, stack, callback); - case OPERATION_MOVE: - throw new IllegalArgumentException("Moving requires providing the source parent."); - case OPERATION_DELETE: - throw new UnsupportedOperationException("Delete isn't currently supported."); - default: - throw new UnsupportedOperationException("Unknown operation: " + operationType); - } - } + String jobId = createJobId(); + Intent intent = createBaseIntent(context, jobId, details, stack); - /** - * Tries to start the activity. Returns the job id. - */ - public static String start(Context context, List<DocumentInfo> srcDocs, DocumentInfo srcParent, - DocumentStack stack, @OpType int operationType, Callback callback) { + callback.onOperationResult( + Callback.STATUS_ACCEPTED, details.getOpType(), details.getItemCount()); - if (DEBUG) Log.d(TAG, "Handling generic 'start' call."); + context.startService(intent); - switch (operationType) { - case OPERATION_COPY: - return FileOperations.copy(context, srcDocs, stack, callback); - case OPERATION_MOVE: - return FileOperations.move(context, srcDocs, srcParent, stack, callback); - case OPERATION_DELETE: - throw new UnsupportedOperationException("Delete isn't currently supported."); - default: - throw new UnsupportedOperationException("Unknown operation: " + operationType); - } + return jobId; } @VisibleForTesting @@ -111,107 +84,22 @@ public final class FileOperations { activity.startService(intent); } - @VisibleForTesting - public static String copy(Context context, List<DocumentInfo> srcDocs, - DocumentStack destination, Callback callback) { - String jobId = createJobId(); - if (DEBUG) Log.d(TAG, "Initiating 'copy' operation id: " + jobId); - - Intent intent = createBaseIntent(OPERATION_COPY, context, jobId, srcDocs, destination); - - callback.onOperationResult(Callback.STATUS_ACCEPTED, OPERATION_COPY, srcDocs.size()); - - context.startService(intent); - - return jobId; - } - - /** - * Starts the service for a move operation. - * - * @param jobId A unique jobid for this job. - * Use {@link #createJobId} if you don't have one handy. - * @param srcDocs A list of src files to copy. - * @param srcParent Parent of all the source documents. - * @param destination The move destination stack. - */ - public static String move(Context context, List<DocumentInfo> srcDocs, DocumentInfo srcParent, - DocumentStack destination, Callback callback) { - String jobId = createJobId(); - if (DEBUG) Log.d(TAG, "Initiating 'move' operation id: " + jobId); - - Intent intent = createBaseIntent(OPERATION_MOVE, context, jobId, srcDocs, srcParent, - destination); - - callback.onOperationResult(Callback.STATUS_ACCEPTED, OPERATION_MOVE, srcDocs.size()); - - context.startService(intent); - - return jobId; - } - - /** - * Starts the service for a delete operation. - * - * @param jobId A unique jobid for this job. - * Use {@link #createJobId} if you don't have one handy. - * @param srcDocs A list of src files to delete. - * @param srcParent Parent of all the source documents. - * @return Id of the job. - */ - public static String delete( - Activity activity, List<DocumentInfo> srcDocs, DocumentInfo srcParent, - DocumentStack location) { - String jobId = createJobId(); - if (DEBUG) Log.d(TAG, "Initiating 'delete' operation id " + jobId + "."); - - Intent intent = createBaseIntent(OPERATION_DELETE, activity, jobId, srcDocs, srcParent, - location); - activity.startService(intent); - - return jobId; - } - /** * Starts the service for an operation. * * @param jobId A unique jobid for this job. * Use {@link #createJobId} if you don't have one handy. - * @param srcDocs A list of src files for an operation. + * @param details the clip details that contains source files and their parent * @return Id of the job. */ public static Intent createBaseIntent( - @OpType int operationType, Context context, String jobId, List<DocumentInfo> srcDocs, + Context context, String jobId, ClipDetails details, DocumentStack localeStack) { Intent intent = new Intent(context, FileOperationService.class); intent.putExtra(EXTRA_JOB_ID, jobId); - intent.putParcelableArrayListExtra(EXTRA_SRC_LIST, asArrayList(srcDocs)); - intent.putExtra(EXTRA_STACK, (Parcelable) localeStack); - intent.putExtra(EXTRA_OPERATION, operationType); - - return intent; - } - - /** - * Starts the service for an operation. - * - * @param jobId A unique jobid for this job. - * Use {@link #createJobId} if you don't have one handy. - * @param srcDocs A list of src files to copy. - * @param srcParent Parent of all the source documents. - * @return Id of the job. - */ - public static Intent createBaseIntent( - @OpType int operationType, Context context, String jobId, - List<DocumentInfo> srcDocs, DocumentInfo srcParent, DocumentStack localeStack) { - - Intent intent = new Intent(context, FileOperationService.class); - intent.putExtra(EXTRA_JOB_ID, jobId); - intent.putParcelableArrayListExtra(EXTRA_SRC_LIST, asArrayList(srcDocs)); - intent.putExtra(EXTRA_SRC_PARENT, srcParent); + intent.putExtra(EXTRA_CLIP_DETAILS, details); intent.putExtra(EXTRA_STACK, (Parcelable) localeStack); - intent.putExtra(EXTRA_OPERATION, operationType); return intent; } diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java index fc3a73171add..0b4735f3a0bb 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java @@ -22,6 +22,9 @@ import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID; import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION; import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST; +import static com.android.documentsui.services.FileOperationService.OPERATION_COPY; +import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE; +import static com.android.documentsui.services.FileOperationService.OPERATION_MOVE; import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN; import android.annotation.DrawableRes; @@ -40,6 +43,7 @@ import android.os.RemoteException; import android.provider.DocumentsContract; import android.util.Log; +import com.android.documentsui.ClipDetails; import com.android.documentsui.FilesActivity; import com.android.documentsui.Metrics; import com.android.documentsui.OperationDialogFragment; @@ -53,7 +57,6 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; /** @@ -64,16 +67,17 @@ abstract public class Job implements Runnable { private static final String TAG = "Job"; @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_CREATED, STATE_STARTED, STATE_COMPLETED, STATE_CANCELED}) + @IntDef({STATE_CREATED, STATE_STARTED, STATE_SET_UP, STATE_COMPLETED, STATE_CANCELED}) @interface State {} static final int STATE_CREATED = 0; static final int STATE_STARTED = 1; - static final int STATE_COMPLETED = 2; + static final int STATE_SET_UP = 2; + static final int STATE_COMPLETED = 3; /** * A job is in canceled state as long as {@link #cancel()} is called on it, even after it is * completed. */ - static final int STATE_CANCELED = 3; + static final int STATE_CANCELED = 4; static final String INTENT_TAG_WARNING = "warning"; static final String INTENT_TAG_FAILURE = "failure"; @@ -87,7 +91,9 @@ abstract public class Job implements Runnable { final @OpType int operationType; final String id; final DocumentStack stack; + final ClipDetails details; + int failedFileCount = 0; final ArrayList<DocumentInfo> failedFiles = new ArrayList<>(); final Notification.Builder mProgressBuilder; @@ -97,8 +103,6 @@ abstract public class Job implements Runnable { /** * A simple progressable job, much like an AsyncTask, but with support * for providing various related notification, progress and navigation information. - * @param operationType - * * @param service The service context in which this job is running. * @param appContext The context of the invoking application. This is usually * just {@code getApplicationContext()}. @@ -107,19 +111,21 @@ abstract public class Job implements Runnable { * @param stack The documents stack context relating to this request. This is the * destination in the Files app where the user will be take when the * navigation intent is invoked (presumably from notification). + * @param details details that contains {@link FileOperationService.OpType} */ Job(Context service, Context appContext, Listener listener, - @OpType int operationType, String id, DocumentStack stack) { + String id, DocumentStack stack, ClipDetails details) { - assert(operationType != OPERATION_UNKNOWN); + assert(details.getOpType() != OPERATION_UNKNOWN); this.service = service; this.appContext = appContext; this.listener = listener; - this.operationType = operationType; + this.operationType = details.getOpType(); this.id = id; this.stack = stack; + this.details = details; mProgressBuilder = createProgressBuilder(); } @@ -134,18 +140,29 @@ abstract public class Job implements Runnable { mState = STATE_STARTED; listener.onStart(this); try { - start(); + boolean result = setUp(); + if (result && !isCanceled()) { + mState = STATE_SET_UP; + start(); + } } catch (RuntimeException e) { // No exceptions should be thrown here, as all calls to the provider must be // handled within Job implementations. However, just in case catch them here. Log.e(TAG, "Operation failed due to an unhandled runtime exception.", e); Metrics.logFileOperationErrors(service, operationType, failedFiles); } finally { - mState = (mState == STATE_STARTED) ? STATE_COMPLETED : mState; + mState = (mState == STATE_STARTED || mState == STATE_SET_UP) ? STATE_COMPLETED : mState; listener.onFinished(this); + + // NOTE: If this details is a JumboClipDetails, and it's still referred in primary clip + // at this point, user won't be able to paste it to anywhere else because the underlying + details.dispose(appContext); } } + boolean setUp() { + return true; + } abstract void start(); abstract Notification getSetupNotification(); @@ -201,11 +218,12 @@ abstract public class Job implements Runnable { } void onFileFailed(DocumentInfo file) { + ++failedFileCount; failedFiles.add(file); } final boolean hasFailures() { - return !failedFiles.isEmpty(); + return failedFileCount > 0; } boolean hasWarnings() { @@ -242,7 +260,7 @@ abstract public class Job implements Runnable { final Notification.Builder errorBuilder = new Notification.Builder(service) .setContentTitle(service.getResources().getQuantityString(titleId, - failedFiles.size(), failedFiles.size())) + failedFileCount, failedFileCount)) .setContentText(service.getString(R.string.notification_touch_for_details)) .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) @@ -319,28 +337,29 @@ abstract public class Job implements Runnable { static final Factory instance = new Factory(); Job createCopy(Context service, Context appContext, Listener listener, - String id, DocumentStack stack, List<DocumentInfo> srcs) { - assert(!srcs.isEmpty()); + String id, DocumentStack stack, ClipDetails details) { + assert(details.getOpType() == OPERATION_COPY); + assert(details.getItemCount() > 0); assert(stack.peek().isCreateSupported()); - return new CopyJob(service, appContext, listener, id, stack, srcs); + return new CopyJob(service, appContext, listener, id, stack, details); } Job createMove(Context service, Context appContext, Listener listener, - String id, DocumentStack stack, List<DocumentInfo> srcs, - DocumentInfo srcParent) { - assert(!srcs.isEmpty()); + String id, DocumentStack stack, ClipDetails details) { + assert(details.getOpType() == OPERATION_MOVE); + assert(details.getItemCount() > 0); assert(stack.peek().isCreateSupported()); - return new MoveJob(service, appContext, listener, id, stack, srcs, srcParent); + return new MoveJob(service, appContext, listener, id, stack, details); } Job createDelete(Context service, Context appContext, Listener listener, - String id, DocumentStack stack, List<DocumentInfo> srcs, - DocumentInfo srcParent) { - assert(!srcs.isEmpty()); + String id, DocumentStack stack, ClipDetails details) { + assert(details.getOpType() == OPERATION_DELETE); + assert(details.getItemCount() > 0); // stack is empty if we delete docs from recent. // we can't currently delete from archives. assert(stack.isEmpty() || stack.peek().isDirectory()); - return new DeleteJob(service, appContext, listener, id, stack, srcs, srcParent); + return new DeleteJob(service, appContext, listener, id, stack, details); } } diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java index 111817132fa1..75c4dc065823 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java +++ b/packages/DocumentsUI/src/com/android/documentsui/services/MoveJob.java @@ -17,28 +17,29 @@ package com.android.documentsui.services; import static com.android.documentsui.Shared.DEBUG; -import static com.android.documentsui.services.FileOperationService.OPERATION_MOVE; import android.app.Notification; import android.app.Notification.Builder; +import android.content.ContentResolver; import android.content.Context; import android.os.RemoteException; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.util.Log; +import com.android.documentsui.ClipDetails; import com.android.documentsui.R; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; -import java.util.List; +import java.io.FileNotFoundException; // TODO: Stop extending CopyJob. final class MoveJob extends CopyJob { private static final String TAG = "MoveJob"; - final DocumentInfo mSrcParent; + DocumentInfo mSrcParent; /** * Moves files to a destination identified by {@code destination}. @@ -47,13 +48,11 @@ final class MoveJob extends CopyJob { * * @see @link {@link Job} constructor for most param descriptions. * - * @param srcs List of files to be moved. - * @param srcParent Parent of all source files. + * @param details {@link ClipDetails} that contains list of files to be moved and their parent */ MoveJob(Context service, Context appContext, Listener listener, - String id, DocumentStack destination, List<DocumentInfo> srcs, DocumentInfo srcParent) { - super(service, appContext, listener, OPERATION_MOVE, id, destination, srcs); - this.mSrcParent = srcParent; + String id, DocumentStack destination, ClipDetails details) { + super(service, appContext, listener, id, destination, details); } @Override @@ -81,6 +80,20 @@ final class MoveJob extends CopyJob { R.plurals.move_error_notification_title, R.drawable.ic_menu_copy); } + @Override + public void start() { + final ContentResolver resolver = appContext.getContentResolver(); + try { + mSrcParent = DocumentInfo.fromUri(resolver, details.getSrcParent()); + } catch(FileNotFoundException e) { + Log.e(TAG, "Failed to create srcParent.", e); + failedFileCount += details.getItemCount(); + return; + } + + super.start(); + } + void processDocument(DocumentInfo src, DocumentInfo srcParent, DocumentInfo dest) throws ResourceException { diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/ClipDetailsTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipDetailsTest.java new file mode 100644 index 000000000000..b0647b89c96b --- /dev/null +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipDetailsTest.java @@ -0,0 +1,163 @@ +/* + * 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 android.net.Uri; +import android.os.AsyncTask; +import android.provider.DocumentsContract; +import android.support.test.filters.MediumTest; +import android.support.test.runner.AndroidJUnit4; + +import com.android.documentsui.services.FileOperationService; +import com.android.documentsui.services.FileOperationService.OpType; +import com.android.documentsui.testing.TestScheduledExecutorService; + +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class ClipDetailsTest { + + private static final String AUTHORITY = "foo"; + private static final @OpType int OP_TYPE = FileOperationService.OPERATION_COPY; + private static final Uri SRC_PARENT = + DocumentsContract.buildDocumentUri(AUTHORITY, Integer.toString(0)); + private static final List<Uri> SHORT_URI_LIST = createList(3); + private static final List<Uri> LONG_URI_LIST = createList(Shared.MAX_DOCS_IN_INTENT + 5); + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private TestScheduledExecutorService mExecutor; + private ClipStorage mStorage; + + @Before + public void setUp() { + mExecutor = new TestScheduledExecutorService(); + AsyncTask.setDefaultExecutor(mExecutor); + + mStorage = new ClipStorage(folder.getRoot()); + } + + @AfterClass + public static void tearDownOnce() { + AsyncTask.setDefaultExecutor(AsyncTask.SERIAL_EXECUTOR); + } + + @Test + public void testOpTypeEquals_shortList() { + ClipDetails details = createDetailsWithShortList(); + + assertEquals(OP_TYPE, details.getOpType()); + } + + @Test + public void testOpTypeEquals_longList() { + ClipDetails details = createDetailsWithLongList(); + + assertEquals(OP_TYPE, details.getOpType()); + } + + @Test + public void testItemCountEquals_shortList() { + ClipDetails details = createDetailsWithShortList(); + + assertEquals(SHORT_URI_LIST.size(), details.getItemCount()); + } + + @Test + public void testItemCountEquals_longList() { + ClipDetails details = createDetailsWithLongList(); + + assertEquals(LONG_URI_LIST.size(), details.getItemCount()); + } + + @Test + public void testGetDocsEquals_shortList() throws Exception { + ClipDetails details = createDetailsWithShortList(); + + assertIterableEquals(SHORT_URI_LIST, details.getDocs(mStorage)); + } + + @Test + public void testGetDocsEquals_longList() throws Exception { + ClipDetails details = createDetailsWithLongList(); + + assertIterableEquals(LONG_URI_LIST, details.getDocs(mStorage)); + } + + @Test + public void testDispose_shortList() throws Exception { + ClipDetails details = createDetailsWithShortList(); + + details.dispose(mStorage); + } + + @Test + public void testDispose_longList() throws Exception { + ClipDetails details = createDetailsWithLongList(); + + details.dispose(mStorage); + } + + private ClipDetails createDetailsWithShortList() { + return ClipDetails.createClipDetails(OP_TYPE, SRC_PARENT, SHORT_URI_LIST, mStorage); + } + + private ClipDetails createDetailsWithLongList() { + ClipDetails details = + ClipDetails.createClipDetails(OP_TYPE, SRC_PARENT, LONG_URI_LIST, mStorage); + + mExecutor.runAll(); + + return details; + } + + private void assertIterableEquals(Iterable<Uri> expected, Iterable<Uri> value) { + Iterator<Uri> expectedIter = expected.iterator(); + Iterator<Uri> valueIter = value.iterator(); + + while (expectedIter.hasNext() && valueIter.hasNext()) { + assertEquals(expectedIter.next(), valueIter.next()); + } + + assertFalse(expectedIter.hasNext()); + assertFalse(expectedIter.hasNext()); + } + + private static List<Uri> createList(int count) { + List<Uri> uris = new ArrayList<>(count); + + for (int i = 0; i < count; ++i) { + uris.add(DocumentsContract.buildDocumentUri(AUTHORITY, Integer.toString(i))); + } + + return uris; + } +} diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java index 986ec793aaad..851000b80cbe 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/ClipStorageTest.java @@ -21,23 +21,26 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import android.net.Uri; +import android.os.AsyncTask; import android.support.test.filters.SmallTest; import android.support.test.runner.AndroidJUnit4; -import com.android.documentsui.ClipStorage.Writer; +import com.android.documentsui.ClipStorage.Reader; import com.android.documentsui.dirlist.TestModel; +import com.android.documentsui.testing.TestScheduledExecutorService; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - +import org.junit.AfterClass; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + @RunWith(AndroidJUnit4.class) @SmallTest public class ClipStorageTest { @@ -48,38 +51,52 @@ public class ClipStorageTest { @Rule public TemporaryFolder folder = new TemporaryFolder(); + private TestScheduledExecutorService mExecutor; + private ClipStorage mStorage; private TestModel mModel; + private long mTag; + @Before public void setUp() { File clipDir = ClipStorage.prepareStorage(folder.getRoot()); mStorage = new ClipStorage(clipDir); + + mExecutor = new TestScheduledExecutorService(); + AsyncTask.setDefaultExecutor(mExecutor); + + mTag = mStorage.createTag(); + } + + @AfterClass + public static void tearDownOnce() { + AsyncTask.setDefaultExecutor(AsyncTask.SERIAL_EXECUTOR); } @Test - public void testWritePrimary() throws Exception { - Writer writer = mStorage.createWriter(); - writeAll(TEST_URIS, writer); + public void testWrite() throws Exception { + writeAll(mTag, TEST_URIS); } @Test public void testRead() throws Exception { - Writer writer = mStorage.createWriter(); - writeAll(TEST_URIS, writer); - long tag = mStorage.savePrimary(); - List<Uri> uris = mStorage.read(tag); + writeAll(mTag, TEST_URIS); + List<Uri> uris = new ArrayList<>(); + try(Reader provider = mStorage.createReader(mTag)) { + for (Uri uri : provider) { + uris.add(uri); + } + } 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); + writeAll(mTag, TEST_URIS); + mStorage.delete(mTag); try { - mStorage.read(tag); + mStorage.createReader(mTag); } catch (IOException expected) {} } @@ -91,21 +108,9 @@ public class ClipStorageTest { 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 void writeAll(long tag, List<Uri> uris) { + new ClipStorage.PersistTask(mStorage, uris, tag).execute(); + mExecutor.runAll(); } private static List<Uri> createList(String... values) { diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java index eb8a061833b2..cd0593946da7 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractCopyJobTest.java @@ -16,6 +16,8 @@ package com.android.documentsui.services; +import static com.android.documentsui.services.FileOperationService.OPERATION_COPY; + import static com.google.common.collect.Lists.newArrayList; import android.net.Uri; @@ -23,7 +25,6 @@ import android.provider.DocumentsContract; import android.test.suitebuilder.annotation.MediumTest; import com.android.documentsui.model.DocumentInfo; -import com.android.documentsui.model.DocumentStack; import java.util.List; @@ -110,7 +111,8 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob public void runNoCopyDirToSelfTest() throws Exception { Uri testDir = mDocs.createFolder(mSrcRoot, "someDir"); - createJob(newArrayList(testDir), + createJob(OPERATION_COPY, + newArrayList(testDir), DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId), testDir).run(); @@ -125,7 +127,8 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob Uri testDir = mDocs.createFolder(mSrcRoot, "someDir"); Uri destDir = mDocs.createFolder(testDir, "theDescendent"); - createJob(newArrayList(testDir), + createJob(OPERATION_COPY, + newArrayList(testDir), DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId), destDir).run(); @@ -160,6 +163,6 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob final T createJob(List<Uri> srcs) throws Exception { Uri srcParent = DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId); Uri destination = DocumentsContract.buildDocumentUri(AUTHORITY, mDestRoot.documentId); - return createJob(srcs, srcParent, destination); + return createJob(OPERATION_COPY, srcs, srcParent, destination); } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java index e559503f8b30..c3cbe3f14458 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/AbstractJobTest.java @@ -24,17 +24,17 @@ import android.content.ContentResolver; import android.content.Context; import android.net.Uri; import android.os.RemoteException; -import android.provider.DocumentsContract; import android.test.AndroidTestCase; import android.test.suitebuilder.annotation.MediumTest; +import com.android.documentsui.ClipDetails; import com.android.documentsui.DocumentsProviderHelper; import com.android.documentsui.StubProvider; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.model.RootInfo; - -import com.google.common.collect.Lists; +import com.android.documentsui.services.FileOperationService.OpType; +import com.android.documentsui.testing.ClipDetailsFactory; import java.util.List; @@ -85,18 +85,16 @@ public abstract class AbstractJobTest<T extends Job> extends AndroidTestCase { mDestRoot = mDocs.getRoot(ROOT_1_ID); } - final T createJob(List<Uri> srcs, Uri srcParent, Uri destination) throws Exception { + final T createJob(@OpType int opType, List<Uri> srcs, Uri srcParent, Uri destination) + throws Exception { DocumentStack stack = new DocumentStack(); stack.push(DocumentInfo.fromUri(mResolver, destination)); + stack.root = mSrcRoot; - List<DocumentInfo> srcDocs = Lists.newArrayList(); - for (Uri src : srcs) { - srcDocs.add(DocumentInfo.fromUri(mResolver, src)); - } - - return createJob(srcDocs, DocumentInfo.fromUri(mResolver, srcParent), stack); + ClipDetails details = ClipDetailsFactory.createClipDetails(opType, srcParent, srcs); + return createJob(details, stack); } - abstract T createJob(List<DocumentInfo> srcs, DocumentInfo srcParent, DocumentStack destination) + abstract T createJob(ClipDetails details, DocumentStack destination) throws Exception; } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java index bb7c01afb65e..eac06ca98001 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/CopyJobTest.java @@ -22,11 +22,9 @@ import android.net.Uri; import android.provider.DocumentsContract.Document; import android.test.suitebuilder.annotation.MediumTest; -import com.android.documentsui.model.DocumentInfo; +import com.android.documentsui.ClipDetails; import com.android.documentsui.model.DocumentStack; -import java.util.List; - @MediumTest public class CopyJobTest extends AbstractCopyJobTest<CopyJob> { @@ -78,10 +76,9 @@ public class CopyJobTest extends AbstractCopyJobTest<CopyJob> { } @Override - // TODO: Stop passing srcParent here, as it's not used for copying. - CopyJob createJob(List<DocumentInfo> srcs, DocumentInfo srcParent, DocumentStack stack) + CopyJob createJob(ClipDetails details, DocumentStack stack) throws Exception { return new CopyJob( - mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs); + mContext, mContext, mJobListener, FileOperations.createJobId(), stack, details); } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java index 722df75593bd..050c7ea5929b 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/DeleteJobTest.java @@ -16,13 +16,15 @@ package com.android.documentsui.services; +import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE; + import static com.google.common.collect.Lists.newArrayList; import android.net.Uri; import android.provider.DocumentsContract; import android.test.suitebuilder.annotation.MediumTest; -import com.android.documentsui.model.DocumentInfo; +import com.android.documentsui.ClipDetails; import com.android.documentsui.model.DocumentStack; import java.util.List; @@ -49,15 +51,14 @@ public class DeleteJobTest extends AbstractJobTest<DeleteJob> { */ private final DeleteJob createJob(List<Uri> srcs, Uri srcParent) throws Exception { Uri stack = DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId); - return createJob(srcs, srcParent, stack); + return createJob(OPERATION_DELETE, srcs, srcParent, stack); } - @Override // TODO: Remove inheritance, as stack is not used for deleting, nor srcParent. - DeleteJob createJob(List<DocumentInfo> srcs, DocumentInfo srcParent, DocumentStack stack) + @Override + DeleteJob createJob(ClipDetails details, DocumentStack stack) throws Exception { return new DeleteJob( - mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs, - srcParent); + mContext, mContext, mJobListener, FileOperations.createJobId(), stack, details); } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java index 9d6e1d7e26e8..e16d5ae52909 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java @@ -29,10 +29,13 @@ import android.net.Uri; import android.test.ServiceTestCase; import android.test.suitebuilder.annotation.MediumTest; +import com.android.documentsui.ClipDetails; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.services.Job.Listener; +import com.android.documentsui.testing.ClipDetailsFactory; import com.android.documentsui.testing.TestHandler; +import com.android.documentsui.testing.TestScheduledExecutorService; import java.util.ArrayList; import java.util.List; @@ -40,6 +43,8 @@ import java.util.List; @MediumTest public class FileOperationServiceTest extends ServiceTestCase<FileOperationService> { + private static final Uri SRC_PARENT = + Uri.parse("content://com.android.documentsui.testing/parent"); private static final DocumentInfo ALPHA_DOC = createDoc("alpha"); private static final DocumentInfo BETA_DOC = createDoc("alpha"); private static final DocumentInfo GAMMA_DOC = createDoc("gamma"); @@ -90,7 +95,11 @@ public class FileOperationServiceTest extends ServiceTestCase<FileOperationServi } public void testRunsCopyJobs_AfterExceptionInJobCreation() throws Exception { - startService(createCopyIntent(new ArrayList<DocumentInfo>(), BETA_DOC)); + try { + startService(createCopyIntent(new ArrayList<>(), BETA_DOC)); + } catch(AssertionError e) { + // Expected AssertionError + } startService(createCopyIntent(newArrayList(GAMMA_DOC), DELTA_DOC)); mJobFactory.assertJobsCreated(1); @@ -219,13 +228,29 @@ public class FileOperationServiceTest extends ServiceTestCase<FileOperationServi DocumentStack stack = new DocumentStack(); stack.push(dest); - return createBaseIntent(OPERATION_COPY, getContext(), createJobId(), files, stack); + List<Uri> uris = new ArrayList<>(files.size()); + for (DocumentInfo file: files) { + uris.add(file.derivedUri); + } + + ClipDetails details = + ClipDetailsFactory.createClipDetails(OPERATION_COPY, SRC_PARENT, uris); + + return createBaseIntent(getContext(), createJobId(), details, stack); } private Intent createDeleteIntent(ArrayList<DocumentInfo> files) { DocumentStack stack = new DocumentStack(); - return createBaseIntent(OPERATION_DELETE, getContext(), createJobId(), files, stack); + List<Uri> uris = new ArrayList<>(files.size()); + for (DocumentInfo file: files) { + uris.add(file.derivedUri); + } + + ClipDetails details = + ClipDetailsFactory.createClipDetails(OPERATION_DELETE, SRC_PARENT, uris); + + return createBaseIntent(getContext(), createJobId(), details, stack); } private static DocumentInfo createDoc(String name) { @@ -291,28 +316,28 @@ public class FileOperationServiceTest extends ServiceTestCase<FileOperationServi @Override Job createCopy(Context service, Context appContext, Listener listener, String id, - DocumentStack stack, List<DocumentInfo> srcs) { + DocumentStack stack, ClipDetails details) { - if (srcs.isEmpty()) { + if (details.getItemCount() == 0) { throw new RuntimeException("Empty srcs not supported!"); } TestJob job = new TestJob( - service, appContext, listener, OPERATION_COPY, id, stack, mJobRunnable); + service, appContext, listener, id, stack, details, mJobRunnable); copyJobs.add(job); return job; } @Override Job createDelete(Context service, Context appContext, Listener listener, String id, - DocumentStack stack, List<DocumentInfo> srcs, DocumentInfo srcParent) { + DocumentStack stack, ClipDetails details) { - if (srcs.isEmpty()) { + if (details.getItemCount() == 0) { throw new RuntimeException("Empty srcs not supported!"); } TestJob job = new TestJob( - service, appContext, listener, OPERATION_DELETE, id, stack, mJobRunnable); + service, appContext, listener, id, stack, details, mJobRunnable); deleteJobs.add(job); return job; diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java index 24181d62e208..fd5c92a0f6e8 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/MoveJobTest.java @@ -20,14 +20,11 @@ import static com.google.common.collect.Lists.newArrayList; import android.net.Uri; import android.provider.DocumentsContract.Document; -import android.provider.DocumentsContract; import android.test.suitebuilder.annotation.MediumTest; -import com.android.documentsui.model.DocumentInfo; +import com.android.documentsui.ClipDetails; import com.android.documentsui.model.DocumentStack; -import java.util.List; - @MediumTest public class MoveJobTest extends AbstractCopyJobTest<MoveJob> { @@ -110,10 +107,9 @@ public class MoveJobTest extends AbstractCopyJobTest<MoveJob> { // TODO: Add test cases for moving when multi-parented. @Override - MoveJob createJob(List<DocumentInfo> srcs, DocumentInfo srcParent, DocumentStack stack) + MoveJob createJob(ClipDetails details, DocumentStack stack) throws Exception { return new MoveJob( - mContext, mContext, mJobListener, FileOperations.createJobId(), stack, srcs, - srcParent); + mContext, mContext, mJobListener, FileOperations.createJobId(), stack, details); } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java index 9104ff02638f..a7e1d665d008 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java @@ -22,12 +22,14 @@ import static junit.framework.Assert.assertTrue; import android.app.Notification; import android.app.Notification.Builder; import android.content.Context; -import android.icu.text.NumberFormat; +import com.android.documentsui.ClipDetails; import com.android.documentsui.R; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; +import java.text.NumberFormat; + public class TestJob extends Job { private boolean mStarted; @@ -37,8 +39,8 @@ public class TestJob extends Job { TestJob( Context service, Context appContext, Listener listener, - int operationType, String id, DocumentStack stack, Runnable startRunnable) { - super(service, appContext, listener, operationType, id, stack); + String id, DocumentStack stack, ClipDetails details, Runnable startRunnable) { + super(service, appContext, listener, id, stack, details); mStartRunnable = startRunnable; } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDetailsFactory.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDetailsFactory.java new file mode 100644 index 000000000000..d8335281dbae --- /dev/null +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDetailsFactory.java @@ -0,0 +1,32 @@ +/* + * 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.net.Uri; + +import com.android.documentsui.ClipDetails; +import com.android.documentsui.services.FileOperationService.OpType; + +import java.util.List; + +public final class ClipDetailsFactory { + private ClipDetailsFactory() {} + + public static ClipDetails createClipDetails(@OpType int opType, Uri srcParent, List<Uri> docs) { + return new ClipDetails.StandardClipDetails(opType, srcParent, docs); + } +} diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestScheduledExecutorService.java index 7bbe70acacdd..f5001ee41d64 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestScheduledExecutorService.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestScheduledExecutorService.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.documentsui.services; +package com.android.documentsui.testing; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.fail; @@ -47,7 +47,7 @@ public class TestScheduledExecutorService implements ScheduledExecutorService { return new ArrayList<>(); } - void assertShutdown() { + public void assertShutdown() { if (!shutdown) { fail("Executor wasn't shut down."); } @@ -109,7 +109,7 @@ public class TestScheduledExecutorService implements ScheduledExecutorService { @Override public void execute(Runnable command) { - throw new UnsupportedOperationException(); + schedule(command, 0, TimeUnit.MILLISECONDS); } @Override @@ -136,13 +136,13 @@ public class TestScheduledExecutorService implements ScheduledExecutorService { throw new UnsupportedOperationException(); } - void runAll() { + public void runAll() { for (TestFuture future : scheduled) { future.runnable.run(); } } - void run(int taskIndex) { + public void run(int taskIndex) { scheduled.get(taskIndex).runnable.run(); } |