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