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