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