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