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