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