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