Home | History | Annotate | Download | only in services
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.documentsui.services;
     18 
     19 import static android.os.SystemClock.elapsedRealtime;
     20 import static android.provider.DocumentsContract.buildChildDocumentsUri;
     21 import static android.provider.DocumentsContract.buildDocumentUri;
     22 import static android.provider.DocumentsContract.getDocumentId;
     23 import static android.provider.DocumentsContract.isChildDocument;
     24 
     25 import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED;
     26 import static com.android.documentsui.base.DocumentInfo.getCursorLong;
     27 import static com.android.documentsui.base.DocumentInfo.getCursorString;
     28 import static com.android.documentsui.base.SharedMinimal.DEBUG;
     29 import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE;
     30 import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_DOCS;
     31 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION_TYPE;
     32 import static com.android.documentsui.services.FileOperationService.MESSAGE_FINISH;
     33 import static com.android.documentsui.services.FileOperationService.MESSAGE_PROGRESS;
     34 import static com.android.documentsui.services.FileOperationService.OPERATION_COPY;
     35 
     36 import android.annotation.StringRes;
     37 import android.app.Notification;
     38 import android.app.Notification.Builder;
     39 import android.app.PendingIntent;
     40 import android.content.ContentProviderClient;
     41 import android.content.Context;
     42 import android.content.Intent;
     43 import android.content.res.AssetFileDescriptor;
     44 import android.database.ContentObserver;
     45 import android.database.Cursor;
     46 import android.net.Uri;
     47 import android.os.FileUtils;
     48 import android.os.Handler;
     49 import android.os.Looper;
     50 import android.os.Message;
     51 import android.os.Messenger;
     52 import android.os.OperationCanceledException;
     53 import android.os.ParcelFileDescriptor;
     54 import android.os.RemoteException;
     55 import android.os.storage.StorageManager;
     56 import android.provider.DocumentsContract;
     57 import android.provider.DocumentsContract.Document;
     58 import android.system.ErrnoException;
     59 import android.system.Int64Ref;
     60 import android.system.Os;
     61 import android.system.OsConstants;
     62 import android.text.format.DateUtils;
     63 import android.util.Log;
     64 import android.webkit.MimeTypeMap;
     65 
     66 import com.android.documentsui.DocumentsApplication;
     67 import com.android.documentsui.Metrics;
     68 import com.android.documentsui.R;
     69 import com.android.documentsui.base.DocumentInfo;
     70 import com.android.documentsui.base.DocumentStack;
     71 import com.android.documentsui.base.Features;
     72 import com.android.documentsui.base.RootInfo;
     73 import com.android.documentsui.clipping.UrisSupplier;
     74 import com.android.documentsui.roots.ProvidersCache;
     75 import com.android.documentsui.services.FileOperationService.OpType;
     76 
     77 import libcore.io.IoUtils;
     78 
     79 import java.io.FileDescriptor;
     80 import java.io.FileNotFoundException;
     81 import java.io.IOException;
     82 import java.io.InputStream;
     83 import java.io.SyncFailedException;
     84 import java.text.NumberFormat;
     85 import java.util.ArrayList;
     86 
     87 class CopyJob extends ResolvedResourcesJob {
     88 
     89     private static final String TAG = "CopyJob";
     90 
     91     private static final long LOADING_TIMEOUT = 60000; // 1 min
     92 
     93     final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>();
     94     DocumentInfo mDstInfo;
     95 
     96     private final Handler mHandler = new Handler(Looper.getMainLooper());
     97     private final Messenger mMessenger;
     98 
     99     private long mStartTime = -1;
    100     private long mBytesRequired;
    101     private volatile long mBytesCopied;
    102 
    103     // Speed estimation.
    104     private long mBytesCopiedSample;
    105     private long mSampleTime;
    106     private long mSpeed;
    107     private long mRemainingTime;
    108 
    109     /**
    110      * @see @link {@link Job} constructor for most param descriptions.
    111      */
    112     CopyJob(Context service, Listener listener, String id, DocumentStack destination,
    113             UrisSupplier srcs, Messenger messenger, Features features) {
    114         this(service, listener, id, OPERATION_COPY, destination, srcs, messenger, features);
    115     }
    116 
    117     CopyJob(Context service, Listener listener, String id, @OpType int opType,
    118             DocumentStack destination, UrisSupplier srcs, Messenger messenger, Features features) {
    119         super(service, listener, id, opType, destination, srcs, features);
    120         mDstInfo = destination.peek();
    121         mMessenger = messenger;
    122 
    123         assert(srcs.getItemCount() > 0);
    124     }
    125 
    126     @Override
    127     Builder createProgressBuilder() {
    128         return super.createProgressBuilder(
    129                 service.getString(R.string.copy_notification_title),
    130                 R.drawable.ic_menu_copy,
    131                 service.getString(android.R.string.cancel),
    132                 R.drawable.ic_cab_cancel);
    133     }
    134 
    135     @Override
    136     public Notification getSetupNotification() {
    137         return getSetupNotification(service.getString(R.string.copy_preparing));
    138     }
    139 
    140     Notification getProgressNotification(@StringRes int msgId) {
    141         updateRemainingTimeEstimate();
    142 
    143         if (mBytesRequired >= 0) {
    144             double completed = (double) this.mBytesCopied / mBytesRequired;
    145             mProgressBuilder.setProgress(100, (int) (completed * 100), false);
    146             mProgressBuilder.setSubText(
    147                     NumberFormat.getPercentInstance().format(completed));
    148         } else {
    149             // If the total file size failed to compute on some files, then show
    150             // an indeterminate spinner. CopyJob would most likely fail on those
    151             // files while copying, but would continue with another files.
    152             // Also, if the total size is 0 bytes, show an indeterminate spinner.
    153             mProgressBuilder.setProgress(0, 0, true);
    154         }
    155 
    156         if (mRemainingTime > 0) {
    157             mProgressBuilder.setContentText(service.getString(msgId,
    158                     DateUtils.formatDuration(mRemainingTime)));
    159         } else {
    160             mProgressBuilder.setContentText(null);
    161         }
    162 
    163         return mProgressBuilder.build();
    164     }
    165 
    166     @Override
    167     public Notification getProgressNotification() {
    168         return getProgressNotification(R.string.copy_remaining);
    169     }
    170 
    171     void onBytesCopied(long numBytes) {
    172         this.mBytesCopied += numBytes;
    173     }
    174 
    175     @Override
    176     void finish() {
    177         try {
    178             mMessenger.send(Message.obtain(mHandler, MESSAGE_FINISH, 0, 0));
    179         } catch (RemoteException e) {
    180             // Ignore. Most likely the frontend was killed.
    181         }
    182         super.finish();
    183     }
    184 
    185     /**
    186      * Generates an estimate of the remaining time in the copy.
    187      */
    188     private void updateRemainingTimeEstimate() {
    189         long elapsedTime = elapsedRealtime() - mStartTime;
    190 
    191         // mBytesCopied is modified in worker thread, but this method is called in monitor thread,
    192         // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent.
    193         final long bytesCopied = mBytesCopied;
    194         final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0
    195         final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
    196         if (mSpeed == 0) {
    197             mSpeed = sampleSpeed;
    198         } else {
    199             mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
    200         }
    201 
    202         if (mSampleTime > 0 && mSpeed > 0) {
    203             mRemainingTime = ((mBytesRequired - bytesCopied) * 1000) / mSpeed;
    204         } else {
    205             mRemainingTime = 0;
    206         }
    207 
    208         mSampleTime = elapsedTime;
    209         mBytesCopiedSample = bytesCopied;
    210     }
    211 
    212     @Override
    213     Notification getFailureNotification() {
    214         return getFailureNotification(
    215                 R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy);
    216     }
    217 
    218     @Override
    219     Notification getWarningNotification() {
    220         final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_WARNING);
    221         navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED);
    222         navigateIntent.putExtra(EXTRA_OPERATION_TYPE, operationType);
    223 
    224         navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_DOCS, convertedFiles);
    225 
    226         // TODO: Consider adding a dialog on tapping the notification with a list of
    227         // converted files.
    228         final Notification.Builder warningBuilder = createNotificationBuilder()
    229                 .setContentTitle(service.getResources().getString(
    230                         R.string.notification_copy_files_converted_title))
    231                 .setContentText(service.getString(
    232                         R.string.notification_touch_for_details))
    233                 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent,
    234                         PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
    235                 .setCategory(Notification.CATEGORY_ERROR)
    236                 .setSmallIcon(R.drawable.ic_menu_copy)
    237                 .setAutoCancel(true);
    238         return warningBuilder.build();
    239     }
    240 
    241     @Override
    242     boolean setUp() {
    243         if (!super.setUp()) {
    244             return false;
    245         }
    246 
    247         // Check if user has canceled this task.
    248         if (isCanceled()) {
    249             return false;
    250         }
    251 
    252         try {
    253             mBytesRequired = calculateBytesRequired();
    254         } catch (ResourceException e) {
    255             Log.w(TAG, "Failed to calculate total size. Copying without progress.", e);
    256             mBytesRequired = -1;
    257         }
    258 
    259         // Check if user has canceled this task. We should check it again here as user cancels
    260         // tasks in main thread, but this is running in a worker thread. calculateSize() may
    261         // take a long time during which user can cancel this task, and we don't want to waste
    262         // resources doing useless large chunk of work.
    263         if (isCanceled()) {
    264             return false;
    265         }
    266 
    267         return checkSpace();
    268     }
    269 
    270     @Override
    271     void start() {
    272         mStartTime = elapsedRealtime();
    273         DocumentInfo srcInfo;
    274         for (int i = 0; i < mResolvedDocs.size() && !isCanceled(); ++i) {
    275             srcInfo = mResolvedDocs.get(i);
    276 
    277             if (DEBUG) Log.d(TAG,
    278                     "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")"
    279                     + " to " + mDstInfo.displayName + " (" + mDstInfo.derivedUri + ")");
    280 
    281             try {
    282                 // Copying recursively to itself or one of descendants is not allowed.
    283                 if (mDstInfo.equals(srcInfo) || isDescendentOf(srcInfo, mDstInfo)) {
    284                     Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri);
    285                     onFileFailed(srcInfo);
    286                 } else {
    287                     processDocument(srcInfo, null, mDstInfo);
    288                 }
    289             } catch (ResourceException e) {
    290                 Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e);
    291                 onFileFailed(srcInfo);
    292             }
    293         }
    294 
    295         Metrics.logFileOperation(service, operationType, mResolvedDocs, mDstInfo);
    296     }
    297 
    298     /**
    299      * Checks whether the destination folder has enough space to take all source files.
    300      * @return true if the root has enough space or doesn't provide free space info; otherwise false
    301      */
    302     boolean checkSpace() {
    303         return verifySpaceAvailable(mBytesRequired);
    304     }
    305 
    306     /**
    307      * Checks whether the destination folder has enough space to take files of batchSize
    308      * @param batchSize the total size of files
    309      * @return true if the root has enough space or doesn't provide free space info; otherwise false
    310      */
    311     final boolean verifySpaceAvailable(long batchSize) {
    312         // Default to be true because if batchSize or available space is invalid, we still let the
    313         // copy start anyway.
    314         boolean available = true;
    315         if (batchSize >= 0) {
    316             ProvidersCache cache = DocumentsApplication.getProvidersCache(appContext);
    317 
    318             RootInfo root = stack.getRoot();
    319             // Query root info here instead of using stack.root because the number there may be
    320             // stale.
    321             root = cache.getRootOneshot(root.authority, root.rootId, true);
    322             if (root.availableBytes >= 0) {
    323                 available = (batchSize <= root.availableBytes);
    324             } else {
    325                 Log.w(TAG, root.toString() + " doesn't provide available bytes.");
    326             }
    327         }
    328 
    329         if (!available) {
    330             failureCount = mResolvedDocs.size();
    331             failedDocs.addAll(mResolvedDocs);
    332         }
    333 
    334         return available;
    335     }
    336 
    337     @Override
    338     boolean hasWarnings() {
    339         return !convertedFiles.isEmpty();
    340     }
    341 
    342     /**
    343      * Logs progress on the current copy operation. Displays/Updates the progress notification.
    344      *
    345      * @param bytesCopied
    346      */
    347     private void makeCopyProgress(long bytesCopied) {
    348         final int completed =
    349             mBytesRequired >= 0 ? (int) (100.0 * this.mBytesCopied / mBytesRequired) : -1;
    350         try {
    351             mMessenger.send(Message.obtain(mHandler, MESSAGE_PROGRESS,
    352                     completed, (int) mRemainingTime));
    353         } catch (RemoteException e) {
    354             // Ignore. The frontend may be gone.
    355         }
    356         onBytesCopied(bytesCopied);
    357     }
    358 
    359     /**
    360      * Copies a the given document to the given location.
    361      *
    362      * @param src DocumentInfos for the documents to copy.
    363      * @param srcParent DocumentInfo for the parent of the document to process.
    364      * @param dstDirInfo The destination directory.
    365      * @throws ResourceException
    366      *
    367      * TODO: Stop passing srcParent, as it's not used for copy, but for move only.
    368      */
    369     void processDocument(DocumentInfo src, DocumentInfo srcParent,
    370             DocumentInfo dstDirInfo) throws ResourceException {
    371 
    372         // TODO: When optimized copy kicks in, we'll not making any progress updates.
    373         // For now. Local storage isn't using optimized copy.
    374 
    375         // When copying within the same provider, try to use optimized copying.
    376         // If not supported, then fallback to byte-by-byte copy/move.
    377         if (src.authority.equals(dstDirInfo.authority)) {
    378             if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
    379                 try {
    380                     if (DocumentsContract.copyDocument(getClient(src), src.derivedUri,
    381                             dstDirInfo.derivedUri) != null) {
    382                         Metrics.logFileOperated(
    383                                 appContext, operationType, Metrics.OPMODE_PROVIDER);
    384                         return;
    385                     }
    386                 } catch (RemoteException | RuntimeException e) {
    387                     Log.e(TAG, "Provider side copy failed for: " + src.derivedUri
    388                             + " due to an exception.", e);
    389                     Metrics.logFileOperationFailure(
    390                             appContext, Metrics.SUBFILEOP_QUICK_COPY, src.derivedUri);
    391                 }
    392 
    393                 // If optimized copy fails, then fallback to byte-by-byte copy.
    394                 if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri);
    395             }
    396         }
    397 
    398         // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
    399         byteCopyDocument(src, dstDirInfo);
    400     }
    401 
    402     void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException {
    403         final String dstMimeType;
    404         final String dstDisplayName;
    405 
    406         if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src);
    407         // If the file is virtual, but can be converted to another format, then try to copy it
    408         // as such format. Also, append an extension for the target mime type (if known).
    409         if (src.isVirtual()) {
    410             String[] streamTypes = null;
    411             try {
    412                 streamTypes = getContentResolver().getStreamTypes(src.derivedUri, "*/*");
    413             } catch (RuntimeException e) {
    414                 Metrics.logFileOperationFailure(
    415                         appContext, Metrics.SUBFILEOP_OBTAIN_STREAM_TYPE, src.derivedUri);
    416                 throw new ResourceException(
    417                         "Failed to obtain streamable types for %s due to an exception.",
    418                         src.derivedUri, e);
    419             }
    420             if (streamTypes != null && streamTypes.length > 0) {
    421                 dstMimeType = streamTypes[0];
    422                 final String extension = MimeTypeMap.getSingleton().
    423                         getExtensionFromMimeType(dstMimeType);
    424                 dstDisplayName = src.displayName +
    425                         (extension != null ? "." + extension : src.displayName);
    426             } else {
    427                 Metrics.logFileOperationFailure(
    428                         appContext, Metrics.SUBFILEOP_OBTAIN_STREAM_TYPE, src.derivedUri);
    429                 throw new ResourceException("Cannot copy virtual file %s. No streamable formats "
    430                         + "available.", src.derivedUri);
    431             }
    432         } else {
    433             dstMimeType = src.mimeType;
    434             dstDisplayName = src.displayName;
    435         }
    436 
    437         // Create the target document (either a file or a directory), then copy recursively the
    438         // contents (bytes or children).
    439         Uri dstUri = null;
    440         try {
    441             dstUri = DocumentsContract.createDocument(
    442                     getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName);
    443         } catch (RemoteException | RuntimeException e) {
    444             Metrics.logFileOperationFailure(
    445                     appContext, Metrics.SUBFILEOP_CREATE_DOCUMENT, dest.derivedUri);
    446             throw new ResourceException(
    447                     "Couldn't create destination document " + dstDisplayName + " in directory %s "
    448                     + "due to an exception.", dest.derivedUri, e);
    449         }
    450         if (dstUri == null) {
    451             // If this is a directory, the entire subdir will not be copied over.
    452             Metrics.logFileOperationFailure(
    453                     appContext, Metrics.SUBFILEOP_CREATE_DOCUMENT, dest.derivedUri);
    454             throw new ResourceException(
    455                     "Couldn't create destination document " + dstDisplayName + " in directory %s.",
    456                     dest.derivedUri);
    457         }
    458 
    459         DocumentInfo dstInfo = null;
    460         try {
    461             dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
    462         } catch (FileNotFoundException | RuntimeException e) {
    463             Metrics.logFileOperationFailure(
    464                     appContext, Metrics.SUBFILEOP_QUERY_DOCUMENT, dstUri);
    465             throw new ResourceException("Could not load DocumentInfo for newly created file %s.",
    466                     dstUri);
    467         }
    468 
    469         if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
    470             copyDirectoryHelper(src, dstInfo);
    471         } else {
    472             copyFileHelper(src, dstInfo, dest, dstMimeType);
    473         }
    474     }
    475 
    476     /**
    477      * Handles recursion into a directory and copying its contents. Note that in linux terms, this
    478      * does the equivalent of "cp src/* dst", not "cp -r src dst".
    479      *
    480      * @param srcDir Info of the directory to copy from. The routine will copy the directory's
    481      *            contents, not the directory itself.
    482      * @param destDir Info of the directory to copy to. Must be created beforehand.
    483      * @throws ResourceException
    484      */
    485     private void copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir)
    486             throws ResourceException {
    487         // Recurse into directories. Copy children into the new subdirectory.
    488         final String queryColumns[] = new String[] {
    489                 Document.COLUMN_DISPLAY_NAME,
    490                 Document.COLUMN_DOCUMENT_ID,
    491                 Document.COLUMN_MIME_TYPE,
    492                 Document.COLUMN_SIZE,
    493                 Document.COLUMN_FLAGS
    494         };
    495         Cursor cursor = null;
    496         boolean success = true;
    497         // Iterate over srcs in the directory; copy to the destination directory.
    498         try {
    499             try {
    500                 cursor = queryChildren(srcDir, queryColumns);
    501             } catch (RemoteException | RuntimeException e) {
    502                 Metrics.logFileOperationFailure(
    503                         appContext, Metrics.SUBFILEOP_QUERY_CHILDREN, srcDir.derivedUri);
    504                 throw new ResourceException("Failed to query children of %s due to an exception.",
    505                         srcDir.derivedUri, e);
    506             }
    507 
    508             DocumentInfo src;
    509             while (cursor.moveToNext() && !isCanceled()) {
    510                 try {
    511                     src = DocumentInfo.fromCursor(cursor, srcDir.authority);
    512                     processDocument(src, srcDir, destDir);
    513                 } catch (RuntimeException e) {
    514                     Log.e(TAG, String.format(
    515                             "Failed to recursively process a file %s due to an exception.",
    516                             srcDir.derivedUri.toString()), e);
    517                     success = false;
    518                 }
    519             }
    520         } catch (RuntimeException e) {
    521             Log.e(TAG, String.format(
    522                     "Failed to copy a file %s to %s. ",
    523                     srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e);
    524             success = false;
    525         } finally {
    526             IoUtils.closeQuietly(cursor);
    527         }
    528 
    529         if (!success) {
    530             throw new RuntimeException("Some files failed to copy during a recursive "
    531                     + "directory copy.");
    532         }
    533     }
    534 
    535     /**
    536      * Handles copying a single file.
    537      *
    538      * @param src Info of the file to copy from.
    539      * @param dest Info of the *file* to copy to. Must be created beforehand.
    540      * @param destParent Info of the parent of the destination.
    541      * @param mimeType Mime type for the target. Can be different than source for virtual files.
    542      * @throws ResourceException
    543      */
    544     private void copyFileHelper(DocumentInfo src, DocumentInfo dest, DocumentInfo destParent,
    545             String mimeType) throws ResourceException {
    546         AssetFileDescriptor srcFileAsAsset = null;
    547         ParcelFileDescriptor srcFile = null;
    548         ParcelFileDescriptor dstFile = null;
    549         InputStream in = null;
    550         ParcelFileDescriptor.AutoCloseOutputStream out = null;
    551         boolean success = false;
    552 
    553         try {
    554             // If the file is virtual, but can be converted to another format, then try to copy it
    555             // as such format.
    556             if (src.isVirtual()) {
    557                 try {
    558                     srcFileAsAsset = getClient(src).openTypedAssetFileDescriptor(
    559                                 src.derivedUri, mimeType, null, mSignal);
    560                 } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    561                     Metrics.logFileOperationFailure(
    562                             appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri);
    563                     throw new ResourceException("Failed to open a file as asset for %s due to an "
    564                             + "exception.", src.derivedUri, e);
    565                 }
    566                 srcFile = srcFileAsAsset.getParcelFileDescriptor();
    567                 try {
    568                     in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
    569                 } catch (IOException e) {
    570                     Metrics.logFileOperationFailure(
    571                             appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri);
    572                     throw new ResourceException("Failed to open a file input stream for %s due "
    573                             + "an exception.", src.derivedUri, e);
    574                 }
    575 
    576                 Metrics.logFileOperated(
    577                         appContext, operationType, Metrics.OPMODE_CONVERTED);
    578             } else {
    579                 try {
    580                     srcFile = getClient(src).openFile(src.derivedUri, "r", mSignal);
    581                 } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    582                     Metrics.logFileOperationFailure(
    583                             appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri);
    584                     throw new ResourceException(
    585                             "Failed to open a file for %s due to an exception.", src.derivedUri, e);
    586                 }
    587                 in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
    588 
    589                 Metrics.logFileOperated(
    590                         appContext, operationType, Metrics.OPMODE_CONVENTIONAL);
    591             }
    592 
    593             try {
    594                 dstFile = getClient(dest).openFile(dest.derivedUri, "w", mSignal);
    595             } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    596                 Metrics.logFileOperationFailure(
    597                         appContext, Metrics.SUBFILEOP_OPEN_FILE, dest.derivedUri);
    598                 throw new ResourceException("Failed to open the destination file %s for writing "
    599                         + "due to an exception.", dest.derivedUri, e);
    600             }
    601             out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
    602 
    603             try {
    604                 // If we know the source size, and the destination supports disk
    605                 // space allocation, then allocate the space we'll need. This
    606                 // uses fallocate() under the hood to optimize on-disk layout
    607                 // and prevent us from running out of space during large copies.
    608                 final StorageManager sm = service.getSystemService(StorageManager.class);
    609                 final long srcSize = srcFile.getStatSize();
    610                 final FileDescriptor dstFd = dstFile.getFileDescriptor();
    611                 if (srcSize > 0 && sm.isAllocationSupported(dstFd)) {
    612                     sm.allocateBytes(dstFd, srcSize);
    613                 }
    614 
    615                 try {
    616                     final Int64Ref last = new Int64Ref(0);
    617                     FileUtils.copy(in, out, (long progress) -> {
    618                         final long delta = progress - last.value;
    619                         last.value = progress;
    620                         makeCopyProgress(delta);
    621                     }, mSignal);
    622                 } catch (OperationCanceledException e) {
    623                     if (DEBUG) Log.d(TAG, "Canceled copy mid-copy of: " + src.derivedUri);
    624                     return;
    625                 }
    626 
    627                 // Need to invoke Os#fsync to ensure the file is written to the storage device.
    628                 try {
    629                     Os.fsync(dstFile.getFileDescriptor());
    630                 } catch (ErrnoException error) {
    631                     // fsync will fail with fd of pipes and return EROFS or EINVAL.
    632                     if (error.errno != OsConstants.EROFS && error.errno != OsConstants.EINVAL) {
    633                         throw new SyncFailedException(
    634                                 "Failed to sync bytes after copying a file.");
    635                     }
    636                 }
    637 
    638                 // Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush.
    639                 IoUtils.close(dstFile.getFileDescriptor());
    640                 srcFile.checkError();
    641             } catch (IOException e) {
    642                 Metrics.logFileOperationFailure(
    643                         appContext,
    644                         Metrics.SUBFILEOP_WRITE_FILE,
    645                         dest.derivedUri);
    646                 throw new ResourceException(
    647                         "Failed to copy bytes from %s to %s due to an IO exception.",
    648                         src.derivedUri, dest.derivedUri, e);
    649             }
    650 
    651             if (src.isVirtual()) {
    652                convertedFiles.add(src);
    653             }
    654 
    655             success = true;
    656         } finally {
    657             if (!success) {
    658                 if (dstFile != null) {
    659                     try {
    660                         dstFile.closeWithError("Error copying bytes.");
    661                     } catch (IOException closeError) {
    662                         Log.w(TAG, "Error closing destination.", closeError);
    663                     }
    664                 }
    665 
    666                 if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers.");
    667                 mSignal.cancel();
    668                 try {
    669                     deleteDocument(dest, destParent);
    670                 } catch (ResourceException e) {
    671                     Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e);
    672                 }
    673             }
    674 
    675             // This also ensures the file descriptors are closed.
    676             IoUtils.closeQuietly(in);
    677             IoUtils.closeQuietly(out);
    678         }
    679     }
    680 
    681     /**
    682      * Calculates the cumulative size of all the documents in the list. Directories are recursed
    683      * into and totaled up.
    684      *
    685      * @return Size in bytes.
    686      * @throws ResourceException
    687      */
    688     private long calculateBytesRequired() throws ResourceException {
    689         long result = 0;
    690 
    691         for (DocumentInfo src : mResolvedDocs) {
    692             if (src.isDirectory()) {
    693                 // Directories need to be recursed into.
    694                 try {
    695                     result += calculateFileSizesRecursively(getClient(src), src.derivedUri);
    696                 } catch (RemoteException e) {
    697                     throw new ResourceException("Failed to obtain the client for %s.",
    698                             src.derivedUri, e);
    699                 }
    700             } else {
    701                 result += src.size;
    702             }
    703 
    704             if (isCanceled()) {
    705                 return result;
    706             }
    707         }
    708         return result;
    709     }
    710 
    711     /**
    712      * Calculates (recursively) the cumulative size of all the files under the given directory.
    713      *
    714      * @throws ResourceException
    715      */
    716     long calculateFileSizesRecursively(
    717             ContentProviderClient client, Uri uri) throws ResourceException {
    718         final String authority = uri.getAuthority();
    719         final String queryColumns[] = new String[] {
    720                 Document.COLUMN_DOCUMENT_ID,
    721                 Document.COLUMN_MIME_TYPE,
    722                 Document.COLUMN_SIZE
    723         };
    724 
    725         long result = 0;
    726         Cursor cursor = null;
    727         try {
    728             cursor = queryChildren(client, uri, queryColumns);
    729             while (cursor.moveToNext() && !isCanceled()) {
    730                 if (Document.MIME_TYPE_DIR.equals(
    731                         getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
    732                     // Recurse into directories.
    733                     final Uri dirUri = buildDocumentUri(authority,
    734                             getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
    735                     result += calculateFileSizesRecursively(client, dirUri);
    736                 } else {
    737                     // This may return -1 if the size isn't defined. Ignore those cases.
    738                     long size = getCursorLong(cursor, Document.COLUMN_SIZE);
    739                     result += size > 0 ? size : 0;
    740                 }
    741             }
    742         } catch (RemoteException | RuntimeException e) {
    743             throw new ResourceException(
    744                     "Failed to calculate size for %s due to an exception.", uri, e);
    745         } finally {
    746             IoUtils.closeQuietly(cursor);
    747         }
    748 
    749         return result;
    750     }
    751 
    752     /**
    753      * Queries children documents.
    754      *
    755      * SAF allows {@link DocumentsContract#EXTRA_LOADING} in {@link Cursor#getExtras()} to indicate
    756      * there are more data to be loaded. Wait until {@link DocumentsContract#EXTRA_LOADING} is
    757      * false and then return the cursor.
    758      *
    759      * @param srcDir the directory whose children are being loading
    760      * @param queryColumns columns of metadata to load
    761      * @return cursor of all children documents
    762      * @throws RemoteException when the remote throws or waiting for update times out
    763      */
    764     private Cursor queryChildren(DocumentInfo srcDir, String[] queryColumns)
    765             throws RemoteException {
    766         return queryChildren(getClient(srcDir), srcDir.derivedUri, queryColumns);
    767     }
    768 
    769     /**
    770      * Queries children documents.
    771      *
    772      * SAF allows {@link DocumentsContract#EXTRA_LOADING} in {@link Cursor#getExtras()} to indicate
    773      * there are more data to be loaded. Wait until {@link DocumentsContract#EXTRA_LOADING} is
    774      * false and then return the cursor.
    775      *
    776      * @param client the {@link ContentProviderClient} to use to query children
    777      * @param dirDocUri the document Uri of the directory whose children are being loaded
    778      * @param queryColumns columns of metadata to load
    779      * @return cursor of all children documents
    780      * @throws RemoteException when the remote throws or waiting for update times out
    781      */
    782     private Cursor queryChildren(ContentProviderClient client, Uri dirDocUri, String[] queryColumns)
    783             throws RemoteException {
    784         // TODO (b/34459983): Optimize this performance by processing partial result first while provider is loading
    785         // more data. Note we need to skip size calculation to achieve it.
    786         final Uri queryUri = buildChildDocumentsUri(dirDocUri.getAuthority(), getDocumentId(dirDocUri));
    787         Cursor cursor = client.query(
    788                 queryUri, queryColumns, (String) null, null, null);
    789         while (cursor.getExtras().getBoolean(DocumentsContract.EXTRA_LOADING)) {
    790             cursor.registerContentObserver(new DirectoryChildrenObserver(queryUri));
    791             try {
    792                 long start = System.currentTimeMillis();
    793                 synchronized (queryUri) {
    794                     queryUri.wait(LOADING_TIMEOUT);
    795                 }
    796                 if (System.currentTimeMillis() - start > LOADING_TIMEOUT) {
    797                     // Timed out
    798                     throw new RemoteException("Timed out waiting on update for " + queryUri);
    799                 }
    800             } catch (InterruptedException e) {
    801                 // Should never happen
    802                 throw new RuntimeException(e);
    803             }
    804 
    805             // Make another query
    806             cursor = client.query(
    807                     queryUri, queryColumns, (String) null, null, null);
    808         }
    809 
    810         return cursor;
    811     }
    812 
    813     /**
    814      * Returns true if {@code doc} is a descendant of {@code parentDoc}.
    815      * @throws ResourceException
    816      */
    817     boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent)
    818             throws ResourceException {
    819         if (parent.isDirectory() && doc.authority.equals(parent.authority)) {
    820             try {
    821                 return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri);
    822             } catch (RemoteException | RuntimeException e) {
    823                 throw new ResourceException(
    824                         "Failed to check if %s is a child of %s due to an exception.",
    825                         doc.derivedUri, parent.derivedUri, e);
    826             }
    827         }
    828         return false;
    829     }
    830 
    831     @Override
    832     public String toString() {
    833         return new StringBuilder()
    834                 .append("CopyJob")
    835                 .append("{")
    836                 .append("id=" + id)
    837                 .append(", uris=" + mResourceUris)
    838                 .append(", docs=" + mResolvedDocs)
    839                 .append(", destination=" + stack)
    840                 .append("}")
    841                 .toString();
    842     }
    843 
    844     private static class DirectoryChildrenObserver extends ContentObserver {
    845 
    846         private final Object mNotifier;
    847 
    848         private DirectoryChildrenObserver(Object notifier) {
    849             super(new Handler(Looper.getMainLooper()));
    850             assert(notifier != null);
    851             mNotifier = notifier;
    852         }
    853 
    854         @Override
    855         public void onChange(boolean selfChange, Uri uri) {
    856             synchronized (mNotifier) {
    857                 mNotifier.notify();
    858             }
    859         }
    860     }
    861 }
    862