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