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;
24import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED;
25import static com.android.documentsui.Shared.DEBUG;
26import static com.android.documentsui.model.DocumentInfo.getCursorLong;
27import static com.android.documentsui.model.DocumentInfo.getCursorString;
28import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE;
29import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
30import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
31import static com.android.documentsui.services.FileOperationService.OPERATION_COPY;
32
33import android.annotation.StringRes;
34import android.app.Notification;
35import android.app.Notification.Builder;
36import android.app.PendingIntent;
37import android.content.ContentProviderClient;
38import android.content.Context;
39import android.content.Intent;
40import android.content.res.AssetFileDescriptor;
41import android.database.Cursor;
42import android.net.Uri;
43import android.os.CancellationSignal;
44import android.os.ParcelFileDescriptor;
45import android.os.RemoteException;
46import android.provider.DocumentsContract;
47import android.provider.DocumentsContract.Document;
48import android.system.ErrnoException;
49import android.system.Os;
50import android.text.format.DateUtils;
51import android.util.Log;
52import android.webkit.MimeTypeMap;
53
54import com.android.documentsui.Metrics;
55import com.android.documentsui.R;
56import com.android.documentsui.model.DocumentInfo;
57import com.android.documentsui.model.DocumentStack;
58import com.android.documentsui.services.FileOperationService.OpType;
59
60import libcore.io.IoUtils;
61
62import java.io.FileNotFoundException;
63import java.io.IOException;
64import java.io.InputStream;
65import java.io.OutputStream;
66import java.text.NumberFormat;
67import java.util.ArrayList;
68import java.util.List;
69
70class CopyJob extends Job {
71
72    private static final String TAG = "CopyJob";
73    private static final int PROGRESS_INTERVAL_MILLIS = 500;
74
75    final List<DocumentInfo> mSrcs;
76    final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>();
77
78    private long mStartTime = -1;
79
80    private long mBatchSize;
81    private long mBytesCopied;
82    private long mLastNotificationTime;
83    // Speed estimation
84    private long mBytesCopiedSample;
85    private long mSampleTime;
86    private long mSpeed;
87    private long mRemainingTime;
88
89    /**
90     * Copies files to a destination identified by {@code destination}.
91     * @see @link {@link Job} constructor for most param descriptions.
92     *
93     * @param srcs List of files to be copied.
94     */
95    CopyJob(Context service, Context appContext, Listener listener,
96            String id, DocumentStack stack, List<DocumentInfo> srcs) {
97        super(service, appContext, listener, OPERATION_COPY, id, stack);
98
99        assert(!srcs.isEmpty());
100        this.mSrcs = srcs;
101    }
102
103    /**
104     * @see @link {@link Job} constructor for most param descriptions.
105     *
106     * @param srcs List of files to be copied.
107     */
108    CopyJob(Context service, Context appContext, Listener listener,
109            @OpType int opType, String id, DocumentStack destination, List<DocumentInfo> srcs) {
110        super(service, appContext, listener, opType, id, destination);
111
112        assert(!srcs.isEmpty());
113        this.mSrcs = srcs;
114    }
115
116    @Override
117    Builder createProgressBuilder() {
118        return super.createProgressBuilder(
119                service.getString(R.string.copy_notification_title),
120                R.drawable.ic_menu_copy,
121                service.getString(android.R.string.cancel),
122                R.drawable.ic_cab_cancel);
123    }
124
125    @Override
126    public Notification getSetupNotification() {
127        return getSetupNotification(service.getString(R.string.copy_preparing));
128    }
129
130    public boolean shouldUpdateProgress() {
131        // Wait a while between updates :)
132        return elapsedRealtime() - mLastNotificationTime > PROGRESS_INTERVAL_MILLIS;
133    }
134
135    Notification getProgressNotification(@StringRes int msgId) {
136        if (mBatchSize >= 0) {
137            double completed = (double) this.mBytesCopied / mBatchSize;
138            mProgressBuilder.setProgress(100, (int) (completed * 100), false);
139            mProgressBuilder.setContentInfo(
140                    NumberFormat.getPercentInstance().format(completed));
141        } else {
142            // If the total file size failed to compute on some files, then show
143            // an indeterminate spinner. CopyJob would most likely fail on those
144            // files while copying, but would continue with another files.
145            // Also, if the total size is 0 bytes, show an indeterminate spinner.
146            mProgressBuilder.setProgress(0, 0, true);
147        }
148
149        if (mRemainingTime > 0) {
150            mProgressBuilder.setContentText(service.getString(msgId,
151                    DateUtils.formatDuration(mRemainingTime)));
152        } else {
153            mProgressBuilder.setContentText(null);
154        }
155
156        // Remember when we last returned progress so we can provide an answer
157        // in shouldUpdateProgress.
158        mLastNotificationTime = elapsedRealtime();
159        return mProgressBuilder.build();
160    }
161
162    public Notification getProgressNotification() {
163        return getProgressNotification(R.string.copy_remaining);
164    }
165
166    void onBytesCopied(long numBytes) {
167        this.mBytesCopied += numBytes;
168    }
169
170    /**
171     * Generates an estimate of the remaining time in the copy.
172     */
173    void updateRemainingTimeEstimate() {
174        long elapsedTime = elapsedRealtime() - mStartTime;
175
176        final long sampleDuration = elapsedTime - mSampleTime;
177        final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
178        if (mSpeed == 0) {
179            mSpeed = sampleSpeed;
180        } else {
181            mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
182        }
183
184        if (mSampleTime > 0 && mSpeed > 0) {
185            mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
186        } else {
187            mRemainingTime = 0;
188        }
189
190        mSampleTime = elapsedTime;
191        mBytesCopiedSample = mBytesCopied;
192    }
193
194    @Override
195    Notification getFailureNotification() {
196        return getFailureNotification(
197                R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy);
198    }
199
200    @Override
201    Notification getWarningNotification() {
202        final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_WARNING);
203        navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED);
204        navigateIntent.putExtra(EXTRA_OPERATION, operationType);
205
206        navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, convertedFiles);
207
208        // TODO: Consider adding a dialog on tapping the notification with a list of
209        // converted files.
210        final Notification.Builder warningBuilder = new Notification.Builder(service)
211                .setContentTitle(service.getResources().getString(
212                        R.string.notification_copy_files_converted_title))
213                .setContentText(service.getString(
214                        R.string.notification_touch_for_details))
215                .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent,
216                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
217                .setCategory(Notification.CATEGORY_ERROR)
218                .setSmallIcon(R.drawable.ic_menu_copy)
219                .setAutoCancel(true);
220        return warningBuilder.build();
221    }
222
223    @Override
224    void start() {
225        mStartTime = elapsedRealtime();
226
227        try {
228            mBatchSize = calculateSize(mSrcs);
229        } catch (ResourceException e) {
230            Log.w(TAG, "Failed to calculate total size. Copying without progress.", e);
231            mBatchSize = -1;
232        }
233
234        DocumentInfo srcInfo;
235        DocumentInfo dstInfo = stack.peek();
236        for (int i = 0; i < mSrcs.size() && !isCanceled(); ++i) {
237            srcInfo = mSrcs.get(i);
238
239            if (DEBUG) Log.d(TAG,
240                    "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")"
241                    + " to " + dstInfo.displayName + " (" + dstInfo.derivedUri + ")");
242
243            try {
244                if (dstInfo.equals(srcInfo) || isDescendentOf(srcInfo, dstInfo)) {
245                    Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri);
246                    onFileFailed(srcInfo);
247                } else {
248                    processDocument(srcInfo, null, dstInfo);
249                }
250            } catch (ResourceException e) {
251                Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e);
252                onFileFailed(srcInfo);
253            }
254        }
255        Metrics.logFileOperation(service, operationType, mSrcs, dstInfo);
256    }
257
258    @Override
259    boolean hasWarnings() {
260        return !convertedFiles.isEmpty();
261    }
262
263    /**
264     * Logs progress on the current copy operation. Displays/Updates the progress notification.
265     *
266     * @param bytesCopied
267     */
268    private void makeCopyProgress(long bytesCopied) {
269        onBytesCopied(bytesCopied);
270        if (shouldUpdateProgress()) {
271            updateRemainingTimeEstimate();
272            listener.onProgress(this);
273        }
274    }
275
276    /**
277     * Copies a the given document to the given location.
278     *
279     * @param src DocumentInfos for the documents to copy.
280     * @param srcParent DocumentInfo for the parent of the document to process.
281     * @param dstDirInfo The destination directory.
282     * @throws ResourceException
283     *
284     * TODO: Stop passing srcParent, as it's not used for copy, but for move only.
285     */
286    void processDocument(DocumentInfo src, DocumentInfo srcParent,
287            DocumentInfo dstDirInfo) throws ResourceException {
288
289        // TODO: When optimized copy kicks in, we'll not making any progress updates.
290        // For now. Local storage isn't using optimized copy.
291
292        // When copying within the same provider, try to use optimized copying.
293        // If not supported, then fallback to byte-by-byte copy/move.
294        if (src.authority.equals(dstDirInfo.authority)) {
295            if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
296                try {
297                    if (DocumentsContract.copyDocument(getClient(src), src.derivedUri,
298                            dstDirInfo.derivedUri) != null) {
299                        return;
300                    }
301                } catch (RemoteException | RuntimeException e) {
302                    Log.e(TAG, "Provider side copy failed for: " + src.derivedUri
303                            + " due to an exception.", e);
304                }
305                // If optimized copy fails, then fallback to byte-by-byte copy.
306                if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri);
307            }
308        }
309
310        // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
311        byteCopyDocument(src, dstDirInfo);
312    }
313
314    void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException {
315        final String dstMimeType;
316        final String dstDisplayName;
317
318        if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src);
319        // If the file is virtual, but can be converted to another format, then try to copy it
320        // as such format. Also, append an extension for the target mime type (if known).
321        if (src.isVirtualDocument()) {
322            String[] streamTypes = null;
323            try {
324                streamTypes = getContentResolver().getStreamTypes(src.derivedUri, "*/*");
325            } catch (RuntimeException e) {
326                throw new ResourceException(
327                        "Failed to obtain streamable types for %s due to an exception.",
328                        src.derivedUri, e);
329            }
330            if (streamTypes != null && streamTypes.length > 0) {
331                dstMimeType = streamTypes[0];
332                final String extension = MimeTypeMap.getSingleton().
333                        getExtensionFromMimeType(dstMimeType);
334                dstDisplayName = src.displayName +
335                        (extension != null ? "." + extension : src.displayName);
336            } else {
337                throw new ResourceException("Cannot copy virtual file %s. No streamable formats "
338                        + "available.", src.derivedUri);
339            }
340        } else {
341            dstMimeType = src.mimeType;
342            dstDisplayName = src.displayName;
343        }
344
345        // Create the target document (either a file or a directory), then copy recursively the
346        // contents (bytes or children).
347        Uri dstUri = null;
348        try {
349            dstUri = DocumentsContract.createDocument(
350                    getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName);
351        } catch (RemoteException | RuntimeException e) {
352            throw new ResourceException(
353                    "Couldn't create destination document " + dstDisplayName + " in directory %s "
354                    + "due to an exception.", dest.derivedUri, e);
355        }
356        if (dstUri == null) {
357            // If this is a directory, the entire subdir will not be copied over.
358            throw new ResourceException(
359                    "Couldn't create destination document " + dstDisplayName + " in directory %s.",
360                    dest.derivedUri);
361        }
362
363        DocumentInfo dstInfo = null;
364        try {
365            dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
366        } catch (FileNotFoundException | RuntimeException e) {
367            throw new ResourceException("Could not load DocumentInfo for newly created file %s.",
368                    dstUri);
369        }
370
371        if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
372            copyDirectoryHelper(src, dstInfo);
373        } else {
374            copyFileHelper(src, dstInfo, dest, dstMimeType);
375        }
376    }
377
378    /**
379     * Handles recursion into a directory and copying its contents. Note that in linux terms, this
380     * does the equivalent of "cp src/* dst", not "cp -r src dst".
381     *
382     * @param srcDir Info of the directory to copy from. The routine will copy the directory's
383     *            contents, not the directory itself.
384     * @param destDir Info of the directory to copy to. Must be created beforehand.
385     * @throws ResourceException
386     */
387    private void copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir)
388            throws ResourceException {
389        // Recurse into directories. Copy children into the new subdirectory.
390        final String queryColumns[] = new String[] {
391                Document.COLUMN_DISPLAY_NAME,
392                Document.COLUMN_DOCUMENT_ID,
393                Document.COLUMN_MIME_TYPE,
394                Document.COLUMN_SIZE,
395                Document.COLUMN_FLAGS
396        };
397        Cursor cursor = null;
398        boolean success = true;
399        // Iterate over srcs in the directory; copy to the destination directory.
400        final Uri queryUri = buildChildDocumentsUri(srcDir.authority, srcDir.documentId);
401        try {
402            try {
403                cursor = getClient(srcDir).query(queryUri, queryColumns, null, null, null);
404            } catch (RemoteException | RuntimeException e) {
405                throw new ResourceException("Failed to query children of %s due to an exception.",
406                        srcDir.derivedUri, e);
407            }
408
409            DocumentInfo src;
410            while (cursor.moveToNext() && !isCanceled()) {
411                try {
412                    src = DocumentInfo.fromCursor(cursor, srcDir.authority);
413                    processDocument(src, srcDir, destDir);
414                } catch (RuntimeException e) {
415                    Log.e(TAG, "Failed to recursively process a file %s due to an exception."
416                            .format(srcDir.derivedUri.toString()), e);
417                    success = false;
418                }
419            }
420        } catch (RuntimeException e) {
421            Log.e(TAG, "Failed to copy a file %s to %s. "
422                    .format(srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e);
423            success = false;
424        } finally {
425            IoUtils.closeQuietly(cursor);
426        }
427
428        if (!success) {
429            throw new RuntimeException("Some files failed to copy during a recursive "
430                    + "directory copy.");
431        }
432    }
433
434    /**
435     * Handles copying a single file.
436     *
437     * @param src Info of the file to copy from.
438     * @param dest Info of the *file* to copy to. Must be created beforehand.
439     * @param destParent Info of the parent of the destination.
440     * @param mimeType Mime type for the target. Can be different than source for virtual files.
441     * @throws ResourceException
442     */
443    private void copyFileHelper(DocumentInfo src, DocumentInfo dest, DocumentInfo destParent,
444            String mimeType) throws ResourceException {
445        CancellationSignal canceller = new CancellationSignal();
446        AssetFileDescriptor srcFileAsAsset = null;
447        ParcelFileDescriptor srcFile = null;
448        ParcelFileDescriptor dstFile = null;
449        InputStream in = null;
450        ParcelFileDescriptor.AutoCloseOutputStream out = null;
451        boolean success = false;
452
453        try {
454            // If the file is virtual, but can be converted to another format, then try to copy it
455            // as such format.
456            if (src.isVirtualDocument()) {
457                try {
458                    srcFileAsAsset = getClient(src).openTypedAssetFileDescriptor(
459                                src.derivedUri, mimeType, null, canceller);
460                } catch (FileNotFoundException | RemoteException | RuntimeException e) {
461                    throw new ResourceException("Failed to open a file as asset for %s due to an "
462                            + "exception.", src.derivedUri, e);
463                }
464                srcFile = srcFileAsAsset.getParcelFileDescriptor();
465                try {
466                    in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
467                } catch (IOException e) {
468                    throw new ResourceException("Failed to open a file input stream for %s due "
469                            + "an exception.", src.derivedUri, e);
470                }
471            } else {
472                try {
473                    srcFile = getClient(src).openFile(src.derivedUri, "r", canceller);
474                } catch (FileNotFoundException | RemoteException | RuntimeException e) {
475                    throw new ResourceException(
476                            "Failed to open a file for %s due to an exception.", src.derivedUri, e);
477                }
478                in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
479            }
480
481            try {
482                dstFile = getClient(dest).openFile(dest.derivedUri, "w", canceller);
483            } catch (FileNotFoundException | RemoteException | RuntimeException e) {
484                throw new ResourceException("Failed to open the destination file %s for writing "
485                        + "due to an exception.", dest.derivedUri, e);
486            }
487            out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
488
489            byte[] buffer = new byte[32 * 1024];
490            int len;
491            try {
492                while ((len = in.read(buffer)) != -1) {
493                    if (isCanceled()) {
494                        if (DEBUG) Log.d(TAG, "Canceled copy mid-copy of: " + src.derivedUri);
495                        return;
496                    }
497                    out.write(buffer, 0, len);
498                    makeCopyProgress(len);
499                }
500
501                // Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush.
502                IoUtils.close(dstFile.getFileDescriptor());
503                srcFile.checkError();
504            } catch (IOException e) {
505                throw new ResourceException(
506                        "Failed to copy bytes from %s to %s due to an IO exception.",
507                        src.derivedUri, dest.derivedUri, e);
508            }
509
510            if (src.isVirtualDocument()) {
511               convertedFiles.add(src);
512            }
513
514            success = true;
515        } finally {
516            if (!success) {
517                if (dstFile != null) {
518                    try {
519                        dstFile.closeWithError("Error copying bytes.");
520                    } catch (IOException closeError) {
521                        Log.w(TAG, "Error closing destination.", closeError);
522                    }
523                }
524
525                if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers.");
526                canceller.cancel();
527                try {
528                    deleteDocument(dest, destParent);
529                } catch (ResourceException e) {
530                    Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e);
531                }
532            }
533
534            // This also ensures the file descriptors are closed.
535            IoUtils.closeQuietly(in);
536            IoUtils.closeQuietly(out);
537        }
538    }
539
540    /**
541     * Calculates the cumulative size of all the documents in the list. Directories are recursed
542     * into and totaled up.
543     *
544     * @param srcs
545     * @return Size in bytes.
546     * @throws ResourceException
547     */
548    private long calculateSize(List<DocumentInfo> srcs) throws ResourceException {
549        long result = 0;
550
551        for (DocumentInfo src : srcs) {
552            if (src.isDirectory()) {
553                // Directories need to be recursed into.
554                try {
555                    result += calculateFileSizesRecursively(getClient(src), src.derivedUri);
556                } catch (RemoteException e) {
557                    throw new ResourceException("Failed to obtain the client for %s.",
558                            src.derivedUri);
559                }
560            } else {
561                result += src.size;
562            }
563        }
564        return result;
565    }
566
567    /**
568     * Calculates (recursively) the cumulative size of all the files under the given directory.
569     *
570     * @throws ResourceException
571     */
572    private static long calculateFileSizesRecursively(
573            ContentProviderClient client, Uri uri) throws ResourceException {
574        final String authority = uri.getAuthority();
575        final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri));
576        final String queryColumns[] = new String[] {
577                Document.COLUMN_DOCUMENT_ID,
578                Document.COLUMN_MIME_TYPE,
579                Document.COLUMN_SIZE
580        };
581
582        long result = 0;
583        Cursor cursor = null;
584        try {
585            cursor = client.query(queryUri, queryColumns, null, null, null);
586            while (cursor.moveToNext()) {
587                if (Document.MIME_TYPE_DIR.equals(
588                        getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
589                    // Recurse into directories.
590                    final Uri dirUri = buildDocumentUri(authority,
591                            getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
592                    result += calculateFileSizesRecursively(client, dirUri);
593                } else {
594                    // This may return -1 if the size isn't defined. Ignore those cases.
595                    long size = getCursorLong(cursor, Document.COLUMN_SIZE);
596                    result += size > 0 ? size : 0;
597                }
598            }
599        } catch (RemoteException | RuntimeException e) {
600            throw new ResourceException(
601                    "Failed to calculate size for %s due to an exception.", uri, e);
602        } finally {
603            IoUtils.closeQuietly(cursor);
604        }
605
606        return result;
607    }
608
609    /**
610     * Returns true if {@code doc} is a descendant of {@code parentDoc}.
611     * @throws ResourceException
612     */
613    boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent)
614            throws ResourceException {
615        if (parent.isDirectory() && doc.authority.equals(parent.authority)) {
616            try {
617                return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri);
618            } catch (RemoteException | RuntimeException e) {
619                throw new ResourceException(
620                        "Failed to check if %s is a child of %s due to an exception.",
621                        doc.derivedUri, parent.derivedUri, e);
622            }
623        }
624        return false;
625    }
626
627    @Override
628    public String toString() {
629        return new StringBuilder()
630                .append("CopyJob")
631                .append("{")
632                .append("id=" + id)
633                .append(", srcs=" + mSrcs)
634                .append(", destination=" + stack)
635                .append("}")
636                .toString();
637    }
638}
639