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