Enable directory copying.
- Enable directory picking for copying.
- Implement recursive copying inside the CopyService.
- Pretty up the notification (use an indeterminate notification while
calculating copy size)
- Do two recursive walks: one to determine the size of the copy job,
and then another to actually copy the files.
- Switch to using ContentProviderClient instances, for better error
detection and handling
- Disable copying from the Recents view.
Change-Id: Ieb38cca80edf84a487547b68f0d6b328fc4d7701
diff --git a/src/com/android/documentsui/CopyService.java b/src/com/android/documentsui/CopyService.java
index f135af4..c826aba 100644
--- a/src/com/android/documentsui/CopyService.java
+++ b/src/com/android/documentsui/CopyService.java
@@ -16,19 +16,24 @@
package com.android.documentsui;
+import static com.android.documentsui.model.DocumentInfo.getCursorLong;
+import static com.android.documentsui.model.DocumentInfo.getCursorString;
+
import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
-import android.content.ContentResolver;
+import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
+import android.database.Cursor;
import android.net.Uri;
import android.os.CancellationSignal;
-import android.os.Environment;
import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
import android.os.SystemClock;
import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
import android.text.format.DateUtils;
import android.util.Log;
@@ -36,12 +41,13 @@
import libcore.io.IoUtils;
-import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.NumberFormat;
import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
public class CopyService extends IntentService {
public static final String TAG = "CopyService";
@@ -56,6 +62,7 @@
private volatile boolean mIsCancelled;
// Parameters of the copy job. Requests to an IntentService are serialized so this code only
// needs to deal with one job at a time.
+ private final List<Uri> mFailedFiles;
private long mBatchSize;
private long mBytesCopied;
private long mStartTime;
@@ -65,9 +72,15 @@
private long mSampleTime;
private long mSpeed;
private long mRemainingTime;
+ // Provider clients are acquired for the duration of each copy job. Note that there is an
+ // implicit assumption that all srcs come from the same authority.
+ private ContentProviderClient mSrcClient;
+ private ContentProviderClient mDstClient;
public CopyService() {
super("CopyService");
+
+ mFailedFiles = new ArrayList<Uri>();
}
@Override
@@ -88,27 +101,34 @@
ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
Uri destinationUri = intent.getData();
- setupCopyJob(srcs, destinationUri);
+ try {
+ // Acquire content providers.
+ mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
+ srcs.get(0).authority);
+ mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
+ destinationUri.getAuthority());
- ArrayList<String> failedFilenames = new ArrayList<String>();
- for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) {
- DocumentInfo src = srcs.get(i);
- try {
- copyFile(src, destinationUri);
- } catch (IOException e) {
- Log.e(TAG, "Failed to copy " + src.displayName, e);
- failedFilenames.add(src.displayName);
+ setupCopyJob(srcs, destinationUri);
+
+ for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) {
+ copy(srcs.get(i), destinationUri);
}
+ } catch (Exception e) {
+ // Catch-all to prevent any copy errors from wedging the app.
+ Log.e(TAG, "Exceptions occurred during copying", e);
+ } finally {
+ ContentProviderClient.releaseQuietly(mSrcClient);
+ ContentProviderClient.releaseQuietly(mDstClient);
+
+ // Dismiss the ongoing copy notification when the copy is done.
+ mNotificationManager.cancel(mJobId, 0);
+
+ if (mFailedFiles.size() > 0) {
+ // TODO: Display a notification when an error has occurred.
+ }
+
+ // TODO: Display a toast if the copy was cancelled.
}
-
- if (failedFilenames.size() > 0) {
- // TODO: Display a notification when an error has occurred.
- }
-
- // Dismiss the ongoing copy notification when the copy is done.
- mNotificationManager.cancel(mJobId, 0);
-
- // TODO: Display a toast if the copy was cancelled.
}
@Override
@@ -123,8 +143,10 @@
*
* @param srcs A list of src files to copy.
* @param destinationUri The URI of the destination directory.
+ * @throws RemoteException
*/
- private void setupCopyJob(ArrayList<DocumentInfo> srcs, Uri destinationUri) {
+ private void setupCopyJob(ArrayList<DocumentInfo> srcs, Uri destinationUri)
+ throws RemoteException {
// Create an ID for this copy job. Use the timestamp.
mJobId = String.valueOf(SystemClock.elapsedRealtime());
// Reset the cancellation flag.
@@ -144,13 +166,13 @@
// TODO: Add a content intent to open the destination folder.
// Send an initial progress notification.
+ mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up.
+ mProgressBuilder.setContentText(getString(R.string.copy_preparing));
mNotificationManager.notify(mJobId, 0, mProgressBuilder.build());
// Reset batch parameters.
- mBatchSize = 0;
- for (DocumentInfo doc : srcs) {
- mBatchSize += doc.size;
- }
+ mFailedFiles.clear();
+ mBatchSize = calculateFileSizes(srcs);
mBytesCopied = 0;
mStartTime = SystemClock.elapsedRealtime();
mLastNotificationTime = 0;
@@ -165,6 +187,66 @@
}
/**
+ * Calculates the cumulative size of all the documents in the list. Directories are recursed
+ * into and totaled up.
+ *
+ * @param srcs
+ * @return Size in bytes.
+ * @throws RemoteException
+ */
+ private long calculateFileSizes(List<DocumentInfo> srcs) throws RemoteException {
+ long result = 0;
+ for (DocumentInfo src : srcs) {
+ if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
+ // Directories need to be recursed into.
+ result += calculateFileSizesHelper(src.derivedUri);
+ } else {
+ result += src.size;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Calculates (recursively) the cumulative size of all the files under the given directory.
+ *
+ * @throws RemoteException
+ */
+ private long calculateFileSizesHelper(Uri uri) throws RemoteException {
+ final String authority = uri.getAuthority();
+ final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority,
+ DocumentsContract.getDocumentId(uri));
+ final String queryColumns[] = new String[] {
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_SIZE
+ };
+
+ long result = 0;
+ Cursor cursor = null;
+ try {
+ cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
+ while (cursor.moveToNext()) {
+ if (Document.MIME_TYPE_DIR.equals(
+ getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
+ // Recurse into directories.
+ final Uri subdirUri = DocumentsContract.buildDocumentUri(authority,
+ getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
+ result += calculateFileSizesHelper(subdirUri);
+ } else {
+ // This may return -1 if the size isn't defined. Ignore those cases.
+ long size = getCursorLong(cursor, Document.COLUMN_SIZE);
+ result += size > 0 ? size : 0;
+ }
+ }
+ } finally {
+ IoUtils.closeQuietly(cursor);
+ }
+
+ return result;
+ }
+
+ /**
* Cancels the current copy job, if its ID matches the given ID.
*
* @param intent The cancellation intent.
@@ -173,7 +255,7 @@
final String cancelledId = intent.getStringExtra(EXTRA_CANCEL);
// Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
// cancellation requests from affecting unrelated copy jobs.
- if (java.util.Objects.equals(mJobId, cancelledId)) {
+ if (Objects.equals(mJobId, cancelledId)) {
// Set the cancel flag. This causes the copy loops to exit.
mIsCancelled = true;
// Dismiss the progress notification here rather than in the copy loop. This preserves
@@ -237,21 +319,78 @@
}
/**
- * Copies a file to a given location.
+ * Copies a the given documents to the given location.
*
- * @param srcInfo The source file.
- * @param destinationUri The URI of the destination directory.
- * @throws IOException
+ * @param srcInfo DocumentInfos for the documents to copy.
+ * @param dstDirUri The URI of the destination directory.
+ * @throws RemoteException
*/
- private void copyFile(DocumentInfo srcInfo, Uri destinationUri) throws IOException {
- final Context context = getApplicationContext();
- final ContentResolver resolver = context.getContentResolver();
-
- final Uri writableDstUri = DocumentsContract.buildDocumentUriUsingTree(destinationUri,
- DocumentsContract.getTreeDocumentId(destinationUri));
- final Uri dstFileUri = DocumentsContract.createDocument(resolver, writableDstUri,
+ private void copy(DocumentInfo srcInfo, Uri dstDirUri) throws RemoteException {
+ final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri,
srcInfo.mimeType, srcInfo.displayName);
+ if (dstUri == null) {
+ // If this is a directory, the entire subdir will not be copied over.
+ Log.e(TAG, "Error while copying " + srcInfo.displayName);
+ mFailedFiles.add(srcInfo.derivedUri);
+ return;
+ }
+ if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) {
+ copyDirectoryHelper(srcInfo.derivedUri, dstUri);
+ } else {
+ copyFileHelper(srcInfo.derivedUri, dstUri);
+ }
+ }
+
+ /**
+ * Handles recursion into a directory and copying its contents. Note that in linux terms, this
+ * does the equivalent of "cp src/* dst", not "cp -r src dst".
+ *
+ * @param srcDirUri URI of the directory to copy from. The routine will copy the directory's
+ * contents, not the directory itself.
+ * @param dstDirUri URI of the directory to copy to. Must be created beforehand.
+ * @throws RemoteException
+ */
+ private void copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri) throws RemoteException {
+ // Recurse into directories. Copy children into the new subdirectory.
+ final String queryColumns[] = new String[] {
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_SIZE
+ };
+ final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirUri.getAuthority(),
+ DocumentsContract.getDocumentId(srcDirUri));
+ Cursor cursor = null;
+ try {
+ // Iterate over srcs in the directory; copy to the destination directory.
+ cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
+ while (cursor.moveToNext()) {
+ final String childMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
+ final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri,
+ childMimeType, getCursorString(cursor, Document.COLUMN_DISPLAY_NAME));
+ final Uri childUri = DocumentsContract.buildDocumentUri(srcDirUri.getAuthority(),
+ getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
+ if (Document.MIME_TYPE_DIR.equals(childMimeType)) {
+ copyDirectoryHelper(childUri, dstUri);
+ } else {
+ copyFileHelper(childUri, dstUri);
+ }
+ }
+ } finally {
+ IoUtils.closeQuietly(cursor);
+ }
+ }
+
+ /**
+ * Handles copying a single file.
+ *
+ * @param srcUri URI of the file to copy from.
+ * @param dstUri URI of the *file* to copy to. Must be created beforehand.
+ * @throws RemoteException
+ */
+ private void copyFileHelper(Uri srcUri, Uri dstUri) throws RemoteException {
+ // Copy an individual file.
CancellationSignal canceller = new CancellationSignal();
ParcelFileDescriptor srcFile = null;
ParcelFileDescriptor dstFile = null;
@@ -260,8 +399,8 @@
boolean errorOccurred = false;
try {
- srcFile = resolver.openFileDescriptor(srcInfo.derivedUri, "r", canceller);
- dstFile = resolver.openFileDescriptor(dstFileUri, "w", canceller);
+ srcFile = mSrcClient.openFile(srcUri, "r", canceller);
+ dstFile = mDstClient.openFile(dstUri, "w", canceller);
src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
@@ -275,7 +414,8 @@
dstFile.checkError();
} catch (IOException e) {
errorOccurred = true;
- Log.e(TAG, "Error while copying " + srcInfo.displayName, e);
+ Log.e(TAG, "Error while copying " + srcUri.toString(), e);
+ mFailedFiles.add(srcUri);
} finally {
// This also ensures the file descriptors are closed.
IoUtils.closeQuietly(src);
@@ -285,8 +425,13 @@
if (errorOccurred || mIsCancelled) {
// Clean up half-copied files.
canceller.cancel();
- if (!DocumentsContract.deleteDocument(resolver, dstFileUri)) {
- Log.w(TAG, "Failed to clean up: " + srcInfo.displayName);
+ try {
+ DocumentsContract.deleteDocument(mDstClient, dstUri);
+ } catch (RemoteException e) {
+ Log.w(TAG, "Failed to clean up: " + srcUri, e);
+ // RemoteExceptions usually signal that the connection is dead, so there's no point
+ // attempting to continue. Propagate the exception up so the copy job is cancelled.
+ throw e;
}
}
}
diff --git a/src/com/android/documentsui/DirectoryFragment.java b/src/com/android/documentsui/DirectoryFragment.java
index 83071bd..0e3016d 100644
--- a/src/com/android/documentsui/DirectoryFragment.java
+++ b/src/com/android/documentsui/DirectoryFragment.java
@@ -357,7 +357,12 @@
return;
}
- Uri destination = data.getData();
+ // Because the destination picker is launched using an open tree intent, the URI returned is
+ // a tree URI. Convert it to a document URI.
+ // TODO: Remove this step when the destination picker returns a document URI.
+ final Uri destinationTree = data.getData();
+ final Uri destination = DocumentsContract.buildDocumentUriUsingTree(destinationTree,
+ DocumentsContract.getTreeDocumentId(destinationTree));
List<DocumentInfo> docs = mSelectedDocumentsForCopy;
Intent copyIntent = new Intent(context, CopyService.class);
@@ -506,8 +511,10 @@
open.setVisible(!manageMode);
share.setVisible(manageMode);
delete.setVisible(manageMode);
- // Hide the copy feature by default.
- copy.setVisible(SystemProperties.getBoolean("debug.documentsui.enable_copy", false));
+ // Hide the copy menu item in the recents folder. For now, also hide it by default
+ // unless the debug flag is enabled.
+ copy.setVisible((mType != TYPE_RECENT_OPEN) &&
+ SystemProperties.getBoolean("debug.documentsui.enable_copy", false));
return true;
}
@@ -575,9 +582,7 @@
if (cursor != null) {
final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
- if (!Document.MIME_TYPE_DIR.equals(docMimeType)) {
- valid = isDocumentEnabled(docMimeType, docFlags);
- }
+ valid = isDocumentEnabled(docMimeType, docFlags);
}
if (!valid) {
@@ -606,8 +611,17 @@
private void onShareDocuments(List<DocumentInfo> docs) {
Intent intent;
- if (docs.size() == 1) {
- final DocumentInfo doc = docs.get(0);
+
+ // Filter out directories - those can't be shared.
+ List<DocumentInfo> docsForSend = Lists.newArrayList();
+ for (DocumentInfo doc: docs) {
+ if (!Document.MIME_TYPE_DIR.equals(doc.mimeType)) {
+ docsForSend.add(doc);
+ }
+ }
+
+ if (docsForSend.size() == 1) {
+ final DocumentInfo doc = docsForSend.get(0);
intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -615,14 +629,14 @@
intent.setType(doc.mimeType);
intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri);
- } else if (docs.size() > 1) {
+ } else if (docsForSend.size() > 1) {
intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_DEFAULT);
final ArrayList<String> mimeTypes = Lists.newArrayList();
final ArrayList<Uri> uris = Lists.newArrayList();
- for (DocumentInfo doc : docs) {
+ for (DocumentInfo doc : docsForSend) {
mimeTypes.add(doc.mimeType);
uris.add(doc.derivedUri);
}