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);
             }