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 import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED;
     25 import static com.android.documentsui.Shared.DEBUG;
     26 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
     27 import static com.android.documentsui.model.DocumentInfo.getCursorString;
     28 import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE;
     29 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
     30 import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
     31 import static com.android.documentsui.services.FileOperationService.OPERATION_COPY;
     32 
     33 import android.annotation.StringRes;
     34 import android.app.Notification;
     35 import android.app.Notification.Builder;
     36 import android.app.PendingIntent;
     37 import android.content.ContentProviderClient;
     38 import android.content.Context;
     39 import android.content.Intent;
     40 import android.content.res.AssetFileDescriptor;
     41 import android.database.Cursor;
     42 import android.net.Uri;
     43 import android.os.CancellationSignal;
     44 import android.os.ParcelFileDescriptor;
     45 import android.os.RemoteException;
     46 import android.provider.DocumentsContract;
     47 import android.provider.DocumentsContract.Document;
     48 import android.system.ErrnoException;
     49 import android.system.Os;
     50 import android.text.format.DateUtils;
     51 import android.util.Log;
     52 import android.webkit.MimeTypeMap;
     53 
     54 import com.android.documentsui.Metrics;
     55 import com.android.documentsui.R;
     56 import com.android.documentsui.model.DocumentInfo;
     57 import com.android.documentsui.model.DocumentStack;
     58 import com.android.documentsui.services.FileOperationService.OpType;
     59 
     60 import libcore.io.IoUtils;
     61 
     62 import java.io.FileNotFoundException;
     63 import java.io.IOException;
     64 import java.io.InputStream;
     65 import java.io.OutputStream;
     66 import java.text.NumberFormat;
     67 import java.util.ArrayList;
     68 import java.util.List;
     69 
     70 class CopyJob extends Job {
     71 
     72     private static final String TAG = "CopyJob";
     73     private static final int PROGRESS_INTERVAL_MILLIS = 500;
     74 
     75     final List<DocumentInfo> mSrcs;
     76     final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>();
     77 
     78     private long mStartTime = -1;
     79 
     80     private long mBatchSize;
     81     private long mBytesCopied;
     82     private long mLastNotificationTime;
     83     // Speed estimation
     84     private long mBytesCopiedSample;
     85     private long mSampleTime;
     86     private long mSpeed;
     87     private long mRemainingTime;
     88 
     89     /**
     90      * Copies files to a destination identified by {@code destination}.
     91      * @see @link {@link Job} constructor for most param descriptions.
     92      *
     93      * @param srcs List of files to be copied.
     94      */
     95     CopyJob(Context service, Context appContext, Listener listener,
     96             String id, DocumentStack stack, List<DocumentInfo> srcs) {
     97         super(service, appContext, listener, OPERATION_COPY, id, stack);
     98 
     99         assert(!srcs.isEmpty());
    100         this.mSrcs = srcs;
    101     }
    102 
    103     /**
    104      * @see @link {@link Job} constructor for most param descriptions.
    105      *
    106      * @param srcs List of files to be copied.
    107      */
    108     CopyJob(Context service, Context appContext, Listener listener,
    109             @OpType int opType, String id, DocumentStack destination, List<DocumentInfo> srcs) {
    110         super(service, appContext, listener, opType, id, destination);
    111 
    112         assert(!srcs.isEmpty());
    113         this.mSrcs = srcs;
    114     }
    115 
    116     @Override
    117     Builder createProgressBuilder() {
    118         return super.createProgressBuilder(
    119                 service.getString(R.string.copy_notification_title),
    120                 R.drawable.ic_menu_copy,
    121                 service.getString(android.R.string.cancel),
    122                 R.drawable.ic_cab_cancel);
    123     }
    124 
    125     @Override
    126     public Notification getSetupNotification() {
    127         return getSetupNotification(service.getString(R.string.copy_preparing));
    128     }
    129 
    130     public boolean shouldUpdateProgress() {
    131         // Wait a while between updates :)
    132         return elapsedRealtime() - mLastNotificationTime > PROGRESS_INTERVAL_MILLIS;
    133     }
    134 
    135     Notification getProgressNotification(@StringRes int msgId) {
    136         if (mBatchSize >= 0) {
    137             double completed = (double) this.mBytesCopied / mBatchSize;
    138             mProgressBuilder.setProgress(100, (int) (completed * 100), false);
    139             mProgressBuilder.setContentInfo(
    140                     NumberFormat.getPercentInstance().format(completed));
    141         } else {
    142             // If the total file size failed to compute on some files, then show
    143             // an indeterminate spinner. CopyJob would most likely fail on those
    144             // files while copying, but would continue with another files.
    145             // Also, if the total size is 0 bytes, show an indeterminate spinner.
    146             mProgressBuilder.setProgress(0, 0, true);
    147         }
    148 
    149         if (mRemainingTime > 0) {
    150             mProgressBuilder.setContentText(service.getString(msgId,
    151                     DateUtils.formatDuration(mRemainingTime)));
    152         } else {
    153             mProgressBuilder.setContentText(null);
    154         }
    155 
    156         // Remember when we last returned progress so we can provide an answer
    157         // in shouldUpdateProgress.
    158         mLastNotificationTime = elapsedRealtime();
    159         return mProgressBuilder.build();
    160     }
    161 
    162     public Notification getProgressNotification() {
    163         return getProgressNotification(R.string.copy_remaining);
    164     }
    165 
    166     void onBytesCopied(long numBytes) {
    167         this.mBytesCopied += numBytes;
    168     }
    169 
    170     /**
    171      * Generates an estimate of the remaining time in the copy.
    172      */
    173     void updateRemainingTimeEstimate() {
    174         long elapsedTime = elapsedRealtime() - mStartTime;
    175 
    176         final long sampleDuration = elapsedTime - mSampleTime;
    177         final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
    178         if (mSpeed == 0) {
    179             mSpeed = sampleSpeed;
    180         } else {
    181             mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
    182         }
    183 
    184         if (mSampleTime > 0 && mSpeed > 0) {
    185             mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
    186         } else {
    187             mRemainingTime = 0;
    188         }
    189 
    190         mSampleTime = elapsedTime;
    191         mBytesCopiedSample = mBytesCopied;
    192     }
    193 
    194     @Override
    195     Notification getFailureNotification() {
    196         return getFailureNotification(
    197                 R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy);
    198     }
    199 
    200     @Override
    201     Notification getWarningNotification() {
    202         final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_WARNING);
    203         navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED);
    204         navigateIntent.putExtra(EXTRA_OPERATION, operationType);
    205 
    206         navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, convertedFiles);
    207 
    208         // TODO: Consider adding a dialog on tapping the notification with a list of
    209         // converted files.
    210         final Notification.Builder warningBuilder = new Notification.Builder(service)
    211                 .setContentTitle(service.getResources().getString(
    212                         R.string.notification_copy_files_converted_title))
    213                 .setContentText(service.getString(
    214                         R.string.notification_touch_for_details))
    215                 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent,
    216                         PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
    217                 .setCategory(Notification.CATEGORY_ERROR)
    218                 .setSmallIcon(R.drawable.ic_menu_copy)
    219                 .setAutoCancel(true);
    220         return warningBuilder.build();
    221     }
    222 
    223     @Override
    224     void start() {
    225         mStartTime = elapsedRealtime();
    226 
    227         try {
    228             mBatchSize = calculateSize(mSrcs);
    229         } catch (ResourceException e) {
    230             Log.w(TAG, "Failed to calculate total size. Copying without progress.", e);
    231             mBatchSize = -1;
    232         }
    233 
    234         DocumentInfo srcInfo;
    235         DocumentInfo dstInfo = stack.peek();
    236         for (int i = 0; i < mSrcs.size() && !isCanceled(); ++i) {
    237             srcInfo = mSrcs.get(i);
    238 
    239             if (DEBUG) Log.d(TAG,
    240                     "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")"
    241                     + " to " + dstInfo.displayName + " (" + dstInfo.derivedUri + ")");
    242 
    243             try {
    244                 if (dstInfo.equals(srcInfo) || isDescendentOf(srcInfo, dstInfo)) {
    245                     Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri);
    246                     onFileFailed(srcInfo);
    247                 } else {
    248                     processDocument(srcInfo, null, dstInfo);
    249                 }
    250             } catch (ResourceException e) {
    251                 Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e);
    252                 onFileFailed(srcInfo);
    253             }
    254         }
    255         Metrics.logFileOperation(service, operationType, mSrcs, dstInfo);
    256     }
    257 
    258     @Override
    259     boolean hasWarnings() {
    260         return !convertedFiles.isEmpty();
    261     }
    262 
    263     /**
    264      * Logs progress on the current copy operation. Displays/Updates the progress notification.
    265      *
    266      * @param bytesCopied
    267      */
    268     private void makeCopyProgress(long bytesCopied) {
    269         onBytesCopied(bytesCopied);
    270         if (shouldUpdateProgress()) {
    271             updateRemainingTimeEstimate();
    272             listener.onProgress(this);
    273         }
    274     }
    275 
    276     /**
    277      * Copies a the given document to the given location.
    278      *
    279      * @param src DocumentInfos for the documents to copy.
    280      * @param srcParent DocumentInfo for the parent of the document to process.
    281      * @param dstDirInfo The destination directory.
    282      * @throws ResourceException
    283      *
    284      * TODO: Stop passing srcParent, as it's not used for copy, but for move only.
    285      */
    286     void processDocument(DocumentInfo src, DocumentInfo srcParent,
    287             DocumentInfo dstDirInfo) throws ResourceException {
    288 
    289         // TODO: When optimized copy kicks in, we'll not making any progress updates.
    290         // For now. Local storage isn't using optimized copy.
    291 
    292         // When copying within the same provider, try to use optimized copying.
    293         // If not supported, then fallback to byte-by-byte copy/move.
    294         if (src.authority.equals(dstDirInfo.authority)) {
    295             if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
    296                 try {
    297                     if (DocumentsContract.copyDocument(getClient(src), src.derivedUri,
    298                             dstDirInfo.derivedUri) != null) {
    299                         return;
    300                     }
    301                 } catch (RemoteException | RuntimeException e) {
    302                     Log.e(TAG, "Provider side copy failed for: " + src.derivedUri
    303                             + " due to an exception.", e);
    304                 }
    305                 // If optimized copy fails, then fallback to byte-by-byte copy.
    306                 if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri);
    307             }
    308         }
    309 
    310         // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
    311         byteCopyDocument(src, dstDirInfo);
    312     }
    313 
    314     void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException {
    315         final String dstMimeType;
    316         final String dstDisplayName;
    317 
    318         if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src);
    319         // If the file is virtual, but can be converted to another format, then try to copy it
    320         // as such format. Also, append an extension for the target mime type (if known).
    321         if (src.isVirtualDocument()) {
    322             String[] streamTypes = null;
    323             try {
    324                 streamTypes = getContentResolver().getStreamTypes(src.derivedUri, "*/*");
    325             } catch (RuntimeException e) {
    326                 throw new ResourceException(
    327                         "Failed to obtain streamable types for %s due to an exception.",
    328                         src.derivedUri, e);
    329             }
    330             if (streamTypes != null && streamTypes.length > 0) {
    331                 dstMimeType = streamTypes[0];
    332                 final String extension = MimeTypeMap.getSingleton().
    333                         getExtensionFromMimeType(dstMimeType);
    334                 dstDisplayName = src.displayName +
    335                         (extension != null ? "." + extension : src.displayName);
    336             } else {
    337                 throw new ResourceException("Cannot copy virtual file %s. No streamable formats "
    338                         + "available.", src.derivedUri);
    339             }
    340         } else {
    341             dstMimeType = src.mimeType;
    342             dstDisplayName = src.displayName;
    343         }
    344 
    345         // Create the target document (either a file or a directory), then copy recursively the
    346         // contents (bytes or children).
    347         Uri dstUri = null;
    348         try {
    349             dstUri = DocumentsContract.createDocument(
    350                     getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName);
    351         } catch (RemoteException | RuntimeException e) {
    352             throw new ResourceException(
    353                     "Couldn't create destination document " + dstDisplayName + " in directory %s "
    354                     + "due to an exception.", dest.derivedUri, e);
    355         }
    356         if (dstUri == null) {
    357             // If this is a directory, the entire subdir will not be copied over.
    358             throw new ResourceException(
    359                     "Couldn't create destination document " + dstDisplayName + " in directory %s.",
    360                     dest.derivedUri);
    361         }
    362 
    363         DocumentInfo dstInfo = null;
    364         try {
    365             dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
    366         } catch (FileNotFoundException | RuntimeException e) {
    367             throw new ResourceException("Could not load DocumentInfo for newly created file %s.",
    368                     dstUri);
    369         }
    370 
    371         if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
    372             copyDirectoryHelper(src, dstInfo);
    373         } else {
    374             copyFileHelper(src, dstInfo, dest, dstMimeType);
    375         }
    376     }
    377 
    378     /**
    379      * Handles recursion into a directory and copying its contents. Note that in linux terms, this
    380      * does the equivalent of "cp src/* dst", not "cp -r src dst".
    381      *
    382      * @param srcDir Info of the directory to copy from. The routine will copy the directory's
    383      *            contents, not the directory itself.
    384      * @param destDir Info of the directory to copy to. Must be created beforehand.
    385      * @throws ResourceException
    386      */
    387     private void copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir)
    388             throws ResourceException {
    389         // Recurse into directories. Copy children into the new subdirectory.
    390         final String queryColumns[] = new String[] {
    391                 Document.COLUMN_DISPLAY_NAME,
    392                 Document.COLUMN_DOCUMENT_ID,
    393                 Document.COLUMN_MIME_TYPE,
    394                 Document.COLUMN_SIZE,
    395                 Document.COLUMN_FLAGS
    396         };
    397         Cursor cursor = null;
    398         boolean success = true;
    399         // Iterate over srcs in the directory; copy to the destination directory.
    400         final Uri queryUri = buildChildDocumentsUri(srcDir.authority, srcDir.documentId);
    401         try {
    402             try {
    403                 cursor = getClient(srcDir).query(queryUri, queryColumns, null, null, null);
    404             } catch (RemoteException | RuntimeException e) {
    405                 throw new ResourceException("Failed to query children of %s due to an exception.",
    406                         srcDir.derivedUri, e);
    407             }
    408 
    409             DocumentInfo src;
    410             while (cursor.moveToNext() && !isCanceled()) {
    411                 try {
    412                     src = DocumentInfo.fromCursor(cursor, srcDir.authority);
    413                     processDocument(src, srcDir, destDir);
    414                 } catch (RuntimeException e) {
    415                     Log.e(TAG, "Failed to recursively process a file %s due to an exception."
    416                             .format(srcDir.derivedUri.toString()), e);
    417                     success = false;
    418                 }
    419             }
    420         } catch (RuntimeException e) {
    421             Log.e(TAG, "Failed to copy a file %s to %s. "
    422                     .format(srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e);
    423             success = false;
    424         } finally {
    425             IoUtils.closeQuietly(cursor);
    426         }
    427 
    428         if (!success) {
    429             throw new RuntimeException("Some files failed to copy during a recursive "
    430                     + "directory copy.");
    431         }
    432     }
    433 
    434     /**
    435      * Handles copying a single file.
    436      *
    437      * @param src Info of the file to copy from.
    438      * @param dest Info of the *file* to copy to. Must be created beforehand.
    439      * @param destParent Info of the parent of the destination.
    440      * @param mimeType Mime type for the target. Can be different than source for virtual files.
    441      * @throws ResourceException
    442      */
    443     private void copyFileHelper(DocumentInfo src, DocumentInfo dest, DocumentInfo destParent,
    444             String mimeType) throws ResourceException {
    445         CancellationSignal canceller = new CancellationSignal();
    446         AssetFileDescriptor srcFileAsAsset = null;
    447         ParcelFileDescriptor srcFile = null;
    448         ParcelFileDescriptor dstFile = null;
    449         InputStream in = null;
    450         ParcelFileDescriptor.AutoCloseOutputStream out = null;
    451         boolean success = false;
    452 
    453         try {
    454             // If the file is virtual, but can be converted to another format, then try to copy it
    455             // as such format.
    456             if (src.isVirtualDocument()) {
    457                 try {
    458                     srcFileAsAsset = getClient(src).openTypedAssetFileDescriptor(
    459                                 src.derivedUri, mimeType, null, canceller);
    460                 } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    461                     throw new ResourceException("Failed to open a file as asset for %s due to an "
    462                             + "exception.", src.derivedUri, e);
    463                 }
    464                 srcFile = srcFileAsAsset.getParcelFileDescriptor();
    465                 try {
    466                     in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
    467                 } catch (IOException e) {
    468                     throw new ResourceException("Failed to open a file input stream for %s due "
    469                             + "an exception.", src.derivedUri, e);
    470                 }
    471             } else {
    472                 try {
    473                     srcFile = getClient(src).openFile(src.derivedUri, "r", canceller);
    474                 } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    475                     throw new ResourceException(
    476                             "Failed to open a file for %s due to an exception.", src.derivedUri, e);
    477                 }
    478                 in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
    479             }
    480 
    481             try {
    482                 dstFile = getClient(dest).openFile(dest.derivedUri, "w", canceller);
    483             } catch (FileNotFoundException | RemoteException | RuntimeException e) {
    484                 throw new ResourceException("Failed to open the destination file %s for writing "
    485                         + "due to an exception.", dest.derivedUri, e);
    486             }
    487             out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
    488 
    489             byte[] buffer = new byte[32 * 1024];
    490             int len;
    491             try {
    492                 while ((len = in.read(buffer)) != -1) {
    493                     if (isCanceled()) {
    494                         if (DEBUG) Log.d(TAG, "Canceled copy mid-copy of: " + src.derivedUri);
    495                         return;
    496                     }
    497                     out.write(buffer, 0, len);
    498                     makeCopyProgress(len);
    499                 }
    500 
    501                 // Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush.
    502                 IoUtils.close(dstFile.getFileDescriptor());
    503                 srcFile.checkError();
    504             } catch (IOException e) {
    505                 throw new ResourceException(
    506                         "Failed to copy bytes from %s to %s due to an IO exception.",
    507                         src.derivedUri, dest.derivedUri, e);
    508             }
    509 
    510             if (src.isVirtualDocument()) {
    511                convertedFiles.add(src);
    512             }
    513 
    514             success = true;
    515         } finally {
    516             if (!success) {
    517                 if (dstFile != null) {
    518                     try {
    519                         dstFile.closeWithError("Error copying bytes.");
    520                     } catch (IOException closeError) {
    521                         Log.w(TAG, "Error closing destination.", closeError);
    522                     }
    523                 }
    524 
    525                 if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers.");
    526                 canceller.cancel();
    527                 try {
    528                     deleteDocument(dest, destParent);
    529                 } catch (ResourceException e) {
    530                     Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e);
    531                 }
    532             }
    533 
    534             // This also ensures the file descriptors are closed.
    535             IoUtils.closeQuietly(in);
    536             IoUtils.closeQuietly(out);
    537         }
    538     }
    539 
    540     /**
    541      * Calculates the cumulative size of all the documents in the list. Directories are recursed
    542      * into and totaled up.
    543      *
    544      * @param srcs
    545      * @return Size in bytes.
    546      * @throws ResourceException
    547      */
    548     private long calculateSize(List<DocumentInfo> srcs) throws ResourceException {
    549         long result = 0;
    550 
    551         for (DocumentInfo src : srcs) {
    552             if (src.isDirectory()) {
    553                 // Directories need to be recursed into.
    554                 try {
    555                     result += calculateFileSizesRecursively(getClient(src), src.derivedUri);
    556                 } catch (RemoteException e) {
    557                     throw new ResourceException("Failed to obtain the client for %s.",
    558                             src.derivedUri);
    559                 }
    560             } else {
    561                 result += src.size;
    562             }
    563         }
    564         return result;
    565     }
    566 
    567     /**
    568      * Calculates (recursively) the cumulative size of all the files under the given directory.
    569      *
    570      * @throws ResourceException
    571      */
    572     private static long calculateFileSizesRecursively(
    573             ContentProviderClient client, Uri uri) throws ResourceException {
    574         final String authority = uri.getAuthority();
    575         final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri));
    576         final String queryColumns[] = new String[] {
    577                 Document.COLUMN_DOCUMENT_ID,
    578                 Document.COLUMN_MIME_TYPE,
    579                 Document.COLUMN_SIZE
    580         };
    581 
    582         long result = 0;
    583         Cursor cursor = null;
    584         try {
    585             cursor = client.query(queryUri, queryColumns, null, null, null);
    586             while (cursor.moveToNext()) {
    587                 if (Document.MIME_TYPE_DIR.equals(
    588                         getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
    589                     // Recurse into directories.
    590                     final Uri dirUri = buildDocumentUri(authority,
    591                             getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
    592                     result += calculateFileSizesRecursively(client, dirUri);
    593                 } else {
    594                     // This may return -1 if the size isn't defined. Ignore those cases.
    595                     long size = getCursorLong(cursor, Document.COLUMN_SIZE);
    596                     result += size > 0 ? size : 0;
    597                 }
    598             }
    599         } catch (RemoteException | RuntimeException e) {
    600             throw new ResourceException(
    601                     "Failed to calculate size for %s due to an exception.", uri, e);
    602         } finally {
    603             IoUtils.closeQuietly(cursor);
    604         }
    605 
    606         return result;
    607     }
    608 
    609     /**
    610      * Returns true if {@code doc} is a descendant of {@code parentDoc}.
    611      * @throws ResourceException
    612      */
    613     boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent)
    614             throws ResourceException {
    615         if (parent.isDirectory() && doc.authority.equals(parent.authority)) {
    616             try {
    617                 return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri);
    618             } catch (RemoteException | RuntimeException e) {
    619                 throw new ResourceException(
    620                         "Failed to check if %s is a child of %s due to an exception.",
    621                         doc.derivedUri, parent.derivedUri, e);
    622             }
    623         }
    624         return false;
    625     }
    626 
    627     @Override
    628     public String toString() {
    629         return new StringBuilder()
    630                 .append("CopyJob")
    631                 .append("{")
    632                 .append("id=" + id)
    633                 .append(", srcs=" + mSrcs)
    634                 .append(", destination=" + stack)
    635                 .append("}")
    636                 .toString();
    637     }
    638 }
    639