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