• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.documentsui;
18 
19 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
20 import static com.android.documentsui.model.DocumentInfo.getCursorString;
21 
22 import android.app.IntentService;
23 import android.app.Notification;
24 import android.app.NotificationManager;
25 import android.app.PendingIntent;
26 import android.content.ContentProviderClient;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.content.res.Resources;
30 import android.database.Cursor;
31 import android.net.Uri;
32 import android.os.CancellationSignal;
33 import android.os.ParcelFileDescriptor;
34 import android.os.Parcelable;
35 import android.os.PowerManager;
36 import android.os.RemoteException;
37 import android.os.SystemClock;
38 import android.provider.DocumentsContract;
39 import android.provider.DocumentsContract.Document;
40 import android.text.format.DateUtils;
41 import android.util.Log;
42 import android.widget.Toast;
43 
44 import com.android.documentsui.model.DocumentInfo;
45 import com.android.documentsui.model.DocumentStack;
46 
47 import libcore.io.IoUtils;
48 
49 import java.io.FileNotFoundException;
50 import java.io.IOException;
51 import java.io.InputStream;
52 import java.io.OutputStream;
53 import java.text.NumberFormat;
54 import java.util.ArrayList;
55 import java.util.List;
56 import java.util.Objects;
57 
58 public 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 
CopyService()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      */
start(Context context, List<DocumentInfo> srcDocs, DocumentStack dstStack)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
onStartCommand(Intent intent, int flags, int startId)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
onHandleIntent(Intent intent)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
onCreate()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      */
setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack)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      */
calculateFileSizes(List<DocumentInfo> srcs)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      */
calculateFileSizesHelper(Uri uri)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      */
handleCancel(Intent intent)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      */
makeProgress(long bytesCopied)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      */
updateRemainingTimeEstimate(long elapsedTime)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      */
copy(DocumentInfo srcInfo, DocumentInfo dstDirInfo)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      */
copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri)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      */
copyFileHelper(Uri srcUri, Uri dstUri)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