1/*
2 * Copyright (C) 2015 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;
18
19import static com.android.documentsui.model.DocumentInfo.getCursorLong;
20import static com.android.documentsui.model.DocumentInfo.getCursorString;
21
22import android.app.IntentService;
23import android.app.Notification;
24import android.app.NotificationManager;
25import android.app.PendingIntent;
26import android.content.ContentProviderClient;
27import android.content.Context;
28import android.content.Intent;
29import android.content.res.Resources;
30import android.database.Cursor;
31import android.net.Uri;
32import android.os.CancellationSignal;
33import android.os.ParcelFileDescriptor;
34import android.os.Parcelable;
35import android.os.PowerManager;
36import android.os.RemoteException;
37import android.os.SystemClock;
38import android.provider.DocumentsContract;
39import android.provider.DocumentsContract.Document;
40import android.text.format.DateUtils;
41import android.util.Log;
42import android.widget.Toast;
43
44import com.android.documentsui.model.DocumentInfo;
45import com.android.documentsui.model.DocumentStack;
46
47import libcore.io.IoUtils;
48
49import java.io.FileNotFoundException;
50import java.io.IOException;
51import java.io.InputStream;
52import java.io.OutputStream;
53import java.text.NumberFormat;
54import java.util.ArrayList;
55import java.util.List;
56import java.util.Objects;
57
58public class CopyService extends IntentService {
59    public static final String TAG = "CopyService";
60
61    private static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
62    public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
63    public static final String EXTRA_STACK = "com.android.documentsui.STACK";
64    public static final String EXTRA_FAILURE = "com.android.documentsui.FAILURE";
65
66    // TODO: Move it to a shared file when more operations are implemented.
67    public static final int FAILURE_COPY = 1;
68
69    private PowerManager mPowerManager;
70
71    private NotificationManager mNotificationManager;
72    private Notification.Builder mProgressBuilder;
73
74    // Jobs are serialized but a job ID is used, to avoid mixing up cancellation requests.
75    private String mJobId;
76    private volatile boolean mIsCancelled;
77    // Parameters of the copy job. Requests to an IntentService are serialized so this code only
78    // needs to deal with one job at a time.
79    private final ArrayList<DocumentInfo> mFailedFiles;
80    private long mBatchSize;
81    private long mBytesCopied;
82    private long mStartTime;
83    private long mLastNotificationTime;
84    // Speed estimation
85    private long mBytesCopiedSample;
86    private long mSampleTime;
87    private long mSpeed;
88    private long mRemainingTime;
89    // Provider clients are acquired for the duration of each copy job. Note that there is an
90    // implicit assumption that all srcs come from the same authority.
91    private ContentProviderClient mSrcClient;
92    private ContentProviderClient mDstClient;
93
94    public CopyService() {
95        super("CopyService");
96
97        mFailedFiles = new ArrayList<DocumentInfo>();
98    }
99
100    /**
101     * Starts the service for a copy operation.
102     *
103     * @param context Context for the intent.
104     * @param srcDocs A list of src files to copy.
105     * @param dstStack The copy destination stack.
106     */
107    public static void start(Context context, List<DocumentInfo> srcDocs, DocumentStack dstStack) {
108        final Resources res = context.getResources();
109        final Intent copyIntent = new Intent(context, CopyService.class);
110        copyIntent.putParcelableArrayListExtra(
111                EXTRA_SRC_LIST, new ArrayList<DocumentInfo>(srcDocs));
112        copyIntent.putExtra(EXTRA_STACK, (Parcelable) dstStack);
113
114        Toast.makeText(context,
115                res.getQuantityString(R.plurals.copy_begin, srcDocs.size(), srcDocs.size()),
116                Toast.LENGTH_SHORT).show();
117        context.startService(copyIntent);
118    }
119
120    @Override
121    public int onStartCommand(Intent intent, int flags, int startId) {
122        if (intent.hasExtra(EXTRA_CANCEL)) {
123            handleCancel(intent);
124        }
125        return super.onStartCommand(intent, flags, startId);
126    }
127
128    @Override
129    protected void onHandleIntent(Intent intent) {
130        if (intent.hasExtra(EXTRA_CANCEL)) {
131            handleCancel(intent);
132            return;
133        }
134
135        final PowerManager.WakeLock wakeLock = mPowerManager
136                .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
137        final ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
138        final DocumentStack stack = intent.getParcelableExtra(EXTRA_STACK);
139
140        try {
141            wakeLock.acquire();
142
143            // Acquire content providers.
144            mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
145                    srcs.get(0).authority);
146            mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
147                    stack.peek().authority);
148
149            setupCopyJob(srcs, stack);
150
151            for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) {
152                copy(srcs.get(i), stack.peek());
153            }
154        } catch (Exception e) {
155            // Catch-all to prevent any copy errors from wedging the app.
156            Log.e(TAG, "Exceptions occurred during copying", e);
157        } finally {
158            ContentProviderClient.releaseQuietly(mSrcClient);
159            ContentProviderClient.releaseQuietly(mDstClient);
160
161            wakeLock.release();
162
163            // Dismiss the ongoing copy notification when the copy is done.
164            mNotificationManager.cancel(mJobId, 0);
165
166            if (mFailedFiles.size() > 0) {
167                final Context context = getApplicationContext();
168                final Intent navigateIntent = new Intent(context, DocumentsActivity.class);
169                navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);
170                navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY);
171                navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, mFailedFiles);
172
173                final Notification.Builder errorBuilder = new Notification.Builder(this)
174                        .setContentTitle(context.getResources().
175                                getQuantityString(R.plurals.copy_error_notification_title,
176                                        mFailedFiles.size(), mFailedFiles.size()))
177                        .setContentText(getString(R.string.notification_touch_for_details))
178                        .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent,
179                                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
180                        .setCategory(Notification.CATEGORY_ERROR)
181                        .setSmallIcon(R.drawable.ic_menu_copy)
182                        .setAutoCancel(true);
183                mNotificationManager.notify(mJobId, 0, errorBuilder.build());
184            }
185        }
186    }
187
188    @Override
189    public void onCreate() {
190        super.onCreate();
191        mPowerManager = getSystemService(PowerManager.class);
192        mNotificationManager = getSystemService(NotificationManager.class);
193    }
194
195    /**
196     * Sets up the CopyService to start tracking and sending notifications for the given batch of
197     * files.
198     *
199     * @param srcs A list of src files to copy.
200     * @param stack The copy destination stack.
201     * @throws RemoteException
202     */
203    private void setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack)
204            throws RemoteException {
205        // Create an ID for this copy job. Use the timestamp.
206        mJobId = String.valueOf(SystemClock.elapsedRealtime());
207        // Reset the cancellation flag.
208        mIsCancelled = false;
209
210        final Context context = getApplicationContext();
211        final Intent navigateIntent = new Intent(context, DocumentsActivity.class);
212        navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);
213
214        mProgressBuilder = new Notification.Builder(this)
215                .setContentTitle(getString(R.string.copy_notification_title))
216                .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, 0))
217                .setCategory(Notification.CATEGORY_PROGRESS)
218                .setSmallIcon(R.drawable.ic_menu_copy)
219                .setOngoing(true);
220
221        final Intent cancelIntent = new Intent(this, CopyService.class);
222        cancelIntent.putExtra(EXTRA_CANCEL, mJobId);
223        mProgressBuilder.addAction(R.drawable.ic_cab_cancel,
224                getString(android.R.string.cancel), PendingIntent.getService(this, 0,
225                        cancelIntent,
226                        PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT));
227
228        // Send an initial progress notification.
229        mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up.
230        mProgressBuilder.setContentText(getString(R.string.copy_preparing));
231        mNotificationManager.notify(mJobId, 0, mProgressBuilder.build());
232
233        // Reset batch parameters.
234        mFailedFiles.clear();
235        mBatchSize = calculateFileSizes(srcs);
236        mBytesCopied = 0;
237        mStartTime = SystemClock.elapsedRealtime();
238        mLastNotificationTime = 0;
239        mBytesCopiedSample = 0;
240        mSampleTime = 0;
241        mSpeed = 0;
242        mRemainingTime = 0;
243
244        // TODO: Check preconditions for copy.
245        // - check that the destination has enough space and is writeable?
246        // - check MIME types?
247    }
248
249    /**
250     * Calculates the cumulative size of all the documents in the list. Directories are recursed
251     * into and totaled up.
252     *
253     * @param srcs
254     * @return Size in bytes.
255     * @throws RemoteException
256     */
257    private long calculateFileSizes(List<DocumentInfo> srcs) throws RemoteException {
258        long result = 0;
259        for (DocumentInfo src : srcs) {
260            if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
261                // Directories need to be recursed into.
262                result += calculateFileSizesHelper(src.derivedUri);
263            } else {
264                result += src.size;
265            }
266        }
267        return result;
268    }
269
270    /**
271     * Calculates (recursively) the cumulative size of all the files under the given directory.
272     *
273     * @throws RemoteException
274     */
275    private long calculateFileSizesHelper(Uri uri) throws RemoteException {
276        final String authority = uri.getAuthority();
277        final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority,
278                DocumentsContract.getDocumentId(uri));
279        final String queryColumns[] = new String[] {
280                Document.COLUMN_DOCUMENT_ID,
281                Document.COLUMN_MIME_TYPE,
282                Document.COLUMN_SIZE
283        };
284
285        long result = 0;
286        Cursor cursor = null;
287        try {
288            cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
289            while (cursor.moveToNext()) {
290                if (Document.MIME_TYPE_DIR.equals(
291                        getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
292                    // Recurse into directories.
293                    final Uri subdirUri = DocumentsContract.buildDocumentUri(authority,
294                            getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
295                    result += calculateFileSizesHelper(subdirUri);
296                } else {
297                    // This may return -1 if the size isn't defined. Ignore those cases.
298                    long size = getCursorLong(cursor, Document.COLUMN_SIZE);
299                    result += size > 0 ? size : 0;
300                }
301            }
302        } finally {
303            IoUtils.closeQuietly(cursor);
304        }
305
306        return result;
307    }
308
309    /**
310     * Cancels the current copy job, if its ID matches the given ID.
311     *
312     * @param intent The cancellation intent.
313     */
314    private void handleCancel(Intent intent) {
315        final String cancelledId = intent.getStringExtra(EXTRA_CANCEL);
316        // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
317        // cancellation requests from affecting unrelated copy jobs.  However, if the current job ID
318        // is null, the service most likely crashed and was revived by the incoming cancel intent.
319        // In that case, always allow the cancellation to proceed.
320        if (Objects.equals(mJobId, cancelledId) || mJobId == null) {
321            // Set the cancel flag. This causes the copy loops to exit.
322            mIsCancelled = true;
323            // Dismiss the progress notification here rather than in the copy loop. This preserves
324            // interactivity for the user in case the copy loop is stalled.
325            mNotificationManager.cancel(cancelledId, 0);
326        }
327    }
328
329    /**
330     * Logs progress on the current copy operation. Displays/Updates the progress notification.
331     *
332     * @param bytesCopied
333     */
334    private void makeProgress(long bytesCopied) {
335        mBytesCopied += bytesCopied;
336        double done = (double) mBytesCopied / mBatchSize;
337        String percent = NumberFormat.getPercentInstance().format(done);
338
339        // Update time estimate
340        long currentTime = SystemClock.elapsedRealtime();
341        long elapsedTime = currentTime - mStartTime;
342
343        // Send out progress notifications once a second.
344        if (currentTime - mLastNotificationTime > 1000) {
345            updateRemainingTimeEstimate(elapsedTime);
346            mProgressBuilder.setProgress(100, (int) (done * 100), false);
347            mProgressBuilder.setContentInfo(percent);
348            if (mRemainingTime > 0) {
349                mProgressBuilder.setContentText(getString(R.string.copy_remaining,
350                        DateUtils.formatDuration(mRemainingTime)));
351            } else {
352                mProgressBuilder.setContentText(null);
353            }
354            mNotificationManager.notify(mJobId, 0, mProgressBuilder.build());
355            mLastNotificationTime = currentTime;
356        }
357    }
358
359    /**
360     * Generates an estimate of the remaining time in the copy.
361     *
362     * @param elapsedTime The time elapsed so far.
363     */
364    private void updateRemainingTimeEstimate(long elapsedTime) {
365        final long sampleDuration = elapsedTime - mSampleTime;
366        final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
367        if (mSpeed == 0) {
368            mSpeed = sampleSpeed;
369        } else {
370            mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
371        }
372
373        if (mSampleTime > 0 && mSpeed > 0) {
374            mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
375        } else {
376            mRemainingTime = 0;
377        }
378
379        mSampleTime = elapsedTime;
380        mBytesCopiedSample = mBytesCopied;
381    }
382
383    /**
384     * Copies a the given documents to the given location.
385     *
386     * @param srcInfo DocumentInfos for the documents to copy.
387     * @param dstDirInfo The destination directory.
388     * @throws RemoteException
389     */
390    private void copy(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException {
391        final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirInfo.derivedUri,
392                srcInfo.mimeType, srcInfo.displayName);
393        if (dstUri == null) {
394            // If this is a directory, the entire subdir will not be copied over.
395            Log.e(TAG, "Error while copying " + srcInfo.displayName);
396            mFailedFiles.add(srcInfo);
397            return;
398        }
399
400        if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) {
401            copyDirectoryHelper(srcInfo.derivedUri, dstUri);
402        } else {
403            copyFileHelper(srcInfo.derivedUri, dstUri);
404        }
405    }
406
407    /**
408     * Handles recursion into a directory and copying its contents. Note that in linux terms, this
409     * does the equivalent of "cp src/* dst", not "cp -r src dst".
410     *
411     * @param srcDirUri URI of the directory to copy from. The routine will copy the directory's
412     *            contents, not the directory itself.
413     * @param dstDirUri URI of the directory to copy to. Must be created beforehand.
414     * @throws RemoteException
415     */
416    private void copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri) throws RemoteException {
417        // Recurse into directories. Copy children into the new subdirectory.
418        final String queryColumns[] = new String[] {
419                Document.COLUMN_DISPLAY_NAME,
420                Document.COLUMN_DOCUMENT_ID,
421                Document.COLUMN_MIME_TYPE,
422                Document.COLUMN_SIZE
423        };
424        final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirUri.getAuthority(),
425                DocumentsContract.getDocumentId(srcDirUri));
426        Cursor cursor = null;
427        try {
428            // Iterate over srcs in the directory; copy to the destination directory.
429            cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
430            while (cursor.moveToNext()) {
431                final String childMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
432                final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri,
433                        childMimeType, getCursorString(cursor, Document.COLUMN_DISPLAY_NAME));
434                final Uri childUri = DocumentsContract.buildDocumentUri(srcDirUri.getAuthority(),
435                        getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
436                if (Document.MIME_TYPE_DIR.equals(childMimeType)) {
437                    copyDirectoryHelper(childUri, dstUri);
438                } else {
439                    copyFileHelper(childUri, dstUri);
440                }
441            }
442        } finally {
443            IoUtils.closeQuietly(cursor);
444        }
445    }
446
447    /**
448     * Handles copying a single file.
449     *
450     * @param srcUri URI of the file to copy from.
451     * @param dstUri URI of the *file* to copy to. Must be created beforehand.
452     * @throws RemoteException
453     */
454    private void copyFileHelper(Uri srcUri, Uri dstUri) throws RemoteException {
455        // Copy an individual file.
456        CancellationSignal canceller = new CancellationSignal();
457        ParcelFileDescriptor srcFile = null;
458        ParcelFileDescriptor dstFile = null;
459        InputStream src = null;
460        OutputStream dst = null;
461
462        IOException copyError = null;
463        try {
464            srcFile = mSrcClient.openFile(srcUri, "r", canceller);
465            dstFile = mDstClient.openFile(dstUri, "w", canceller);
466            src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
467            dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
468
469            byte[] buffer = new byte[8192];
470            int len;
471            while (!mIsCancelled && ((len = src.read(buffer)) != -1)) {
472                dst.write(buffer, 0, len);
473                makeProgress(len);
474            }
475
476            srcFile.checkError();
477        } catch (IOException e) {
478            copyError = e;
479            try {
480                dstFile.closeWithError(copyError.getMessage());
481            } catch (IOException closeError) {
482                Log.e(TAG, "Error closing destination", closeError);
483            }
484        } finally {
485            // This also ensures the file descriptors are closed.
486            IoUtils.closeQuietly(src);
487            IoUtils.closeQuietly(dst);
488        }
489
490        if (copyError != null) {
491            // Log errors.
492            Log.e(TAG, "Error while copying " + srcUri.toString(), copyError);
493            try {
494                mFailedFiles.add(DocumentInfo.fromUri(getContentResolver(), srcUri));
495            } catch (FileNotFoundException ignore) {
496                Log.w(TAG, "Source file gone: " + srcUri, copyError);
497              // The source file is gone.
498            }
499        }
500
501        if (copyError != null || mIsCancelled) {
502            // Clean up half-copied files.
503            canceller.cancel();
504            try {
505                DocumentsContract.deleteDocument(mDstClient, dstUri);
506            } catch (RemoteException e) {
507                Log.w(TAG, "Failed to clean up: " + srcUri, e);
508                // RemoteExceptions usually signal that the connection is dead, so there's no point
509                // attempting to continue. Propagate the exception up so the copy job is cancelled.
510                throw e;
511            }
512        }
513    }
514}
515