| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.documentsui; |
| |
| import static com.android.documentsui.model.DocumentInfo.getCursorLong; |
| import static com.android.documentsui.model.DocumentInfo.getCursorString; |
| |
| import android.app.IntentService; |
| import android.app.Notification; |
| import android.app.NotificationManager; |
| import android.app.PendingIntent; |
| import android.content.ContentProviderClient; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.net.Uri; |
| import android.os.CancellationSignal; |
| import android.os.ParcelFileDescriptor; |
| import android.os.Parcelable; |
| import android.os.RemoteException; |
| import android.os.SystemClock; |
| import android.provider.DocumentsContract; |
| import android.provider.DocumentsContract.Document; |
| import android.text.format.DateUtils; |
| import android.util.Log; |
| import android.widget.Toast; |
| |
| import com.android.documentsui.model.DocumentInfo; |
| import com.android.documentsui.model.DocumentStack; |
| |
| import libcore.io.IoUtils; |
| |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.text.NumberFormat; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| |
| public class CopyService extends IntentService { |
| public static final String TAG = "CopyService"; |
| |
| private static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; |
| public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; |
| public static final String EXTRA_STACK = "com.android.documentsui.STACK"; |
| public static final String EXTRA_FAILURE = "com.android.documentsui.FAILURE"; |
| public static final String EXTRA_TRANSFER_MODE = "com.android.documentsui.TRANSFER_MODE"; |
| |
| public static final int TRANSFER_MODE_NONE = 0; |
| public static final int TRANSFER_MODE_COPY = 1; |
| public static final int TRANSFER_MODE_MOVE = 2; |
| |
| // TODO: Move it to a shared file when more operations are implemented. |
| public static final int FAILURE_COPY = 1; |
| |
| private NotificationManager mNotificationManager; |
| private Notification.Builder mProgressBuilder; |
| |
| // Jobs are serialized but a job ID is used, to avoid mixing up cancellation requests. |
| private String mJobId; |
| private volatile boolean mIsCancelled; |
| // Parameters of the copy job. Requests to an IntentService are serialized so this code only |
| // needs to deal with one job at a time. |
| private final ArrayList<DocumentInfo> mFailedFiles; |
| private long mBatchSize; |
| private long mBytesCopied; |
| private long mStartTime; |
| private long mLastNotificationTime; |
| // Speed estimation |
| private long mBytesCopiedSample; |
| private long mSampleTime; |
| private long mSpeed; |
| private long mRemainingTime; |
| // Provider clients are acquired for the duration of each copy job. Note that there is an |
| // implicit assumption that all srcs come from the same authority. |
| private ContentProviderClient mSrcClient; |
| private ContentProviderClient mDstClient; |
| |
| public CopyService() { |
| super("CopyService"); |
| |
| mFailedFiles = new ArrayList<DocumentInfo>(); |
| } |
| |
| /** |
| * Starts the service for a copy operation. |
| * |
| * @param context Context for the intent. |
| * @param srcDocs A list of src files to copy. |
| * @param dstStack The copy destination stack. |
| */ |
| public static void start(Context context, List<DocumentInfo> srcDocs, DocumentStack dstStack, |
| int mode) { |
| final Resources res = context.getResources(); |
| final Intent copyIntent = new Intent(context, CopyService.class); |
| copyIntent.putParcelableArrayListExtra( |
| EXTRA_SRC_LIST, new ArrayList<DocumentInfo>(srcDocs)); |
| copyIntent.putExtra(EXTRA_STACK, (Parcelable) dstStack); |
| copyIntent.putExtra(EXTRA_TRANSFER_MODE, mode); |
| |
| int toastMessage = (mode == TRANSFER_MODE_COPY) ? R.plurals.copy_begin |
| : R.plurals.move_begin; |
| Toast.makeText(context, |
| res.getQuantityString(toastMessage, srcDocs.size(), srcDocs.size()), |
| Toast.LENGTH_SHORT).show(); |
| context.startService(copyIntent); |
| } |
| |
| @Override |
| public int onStartCommand(Intent intent, int flags, int startId) { |
| if (intent.hasExtra(EXTRA_CANCEL)) { |
| handleCancel(intent); |
| } |
| return super.onStartCommand(intent, flags, startId); |
| } |
| |
| @Override |
| protected void onHandleIntent(Intent intent) { |
| if (intent.hasExtra(EXTRA_CANCEL)) { |
| handleCancel(intent); |
| return; |
| } |
| |
| final ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST); |
| final DocumentStack stack = intent.getParcelableExtra(EXTRA_STACK); |
| // Copy by default. |
| final int transferMode = intent.getIntExtra(EXTRA_TRANSFER_MODE, TRANSFER_MODE_COPY); |
| |
| try { |
| // Acquire content providers. |
| mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(), |
| srcs.get(0).authority); |
| mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(), |
| stack.peek().authority); |
| |
| setupCopyJob(srcs, stack, transferMode); |
| |
| for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) { |
| copy(srcs.get(i), stack.peek(), transferMode); |
| } |
| } catch (Exception e) { |
| // Catch-all to prevent any copy errors from wedging the app. |
| Log.e(TAG, "Exceptions occurred during copying", e); |
| } finally { |
| ContentProviderClient.releaseQuietly(mSrcClient); |
| ContentProviderClient.releaseQuietly(mDstClient); |
| |
| // Dismiss the ongoing copy notification when the copy is done. |
| mNotificationManager.cancel(mJobId, 0); |
| |
| if (mFailedFiles.size() > 0) { |
| final Context context = getApplicationContext(); |
| final Intent navigateIntent = new Intent(context, StandaloneActivity.class); |
| navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack); |
| navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY); |
| navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, mFailedFiles); |
| |
| final int titleResourceId = (transferMode == TRANSFER_MODE_COPY ? |
| R.plurals.copy_error_notification_title : |
| R.plurals.move_error_notification_title); |
| final Notification.Builder errorBuilder = new Notification.Builder(this) |
| .setContentTitle(context.getResources().getQuantityString(titleResourceId, |
| mFailedFiles.size(), mFailedFiles.size())) |
| .setContentText(getString(R.string.notification_touch_for_details)) |
| .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, |
| PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) |
| .setCategory(Notification.CATEGORY_ERROR) |
| .setSmallIcon(R.drawable.ic_menu_copy) |
| .setAutoCancel(true); |
| mNotificationManager.notify(mJobId, 0, errorBuilder.build()); |
| } |
| } |
| } |
| |
| @Override |
| public void onCreate() { |
| super.onCreate(); |
| mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); |
| } |
| |
| /** |
| * Sets up the CopyService to start tracking and sending notifications for the given batch of |
| * files. |
| * |
| * @param srcs A list of src files to copy. |
| * @param stack The copy destination stack. |
| * @param transferMode The mode (i.e. copy, or move) |
| * @throws RemoteException |
| */ |
| private void setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack, int transferMode) |
| throws RemoteException { |
| final boolean copying = (transferMode == TRANSFER_MODE_COPY); |
| // Create an ID for this copy job. Use the timestamp. |
| mJobId = String.valueOf(SystemClock.elapsedRealtime()); |
| // Reset the cancellation flag. |
| mIsCancelled = false; |
| |
| final Context context = getApplicationContext(); |
| final Intent navigateIntent = new Intent(context, StandaloneActivity.class); |
| navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack); |
| |
| final String contentTitle = getString(copying ? R.string.copy_notification_title |
| : R.string.move_notification_title); |
| mProgressBuilder = new Notification.Builder(this) |
| .setContentTitle(contentTitle) |
| .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, 0)) |
| .setCategory(Notification.CATEGORY_PROGRESS) |
| .setSmallIcon(R.drawable.ic_menu_copy) |
| .setOngoing(true); |
| |
| final Intent cancelIntent = new Intent(this, CopyService.class); |
| cancelIntent.putExtra(EXTRA_CANCEL, mJobId); |
| mProgressBuilder.addAction(R.drawable.ic_cab_cancel, |
| getString(android.R.string.cancel), PendingIntent.getService(this, 0, |
| cancelIntent, |
| PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT)); |
| |
| // Send an initial progress notification. |
| final String contentText = getString(copying ? R.string.copy_preparing |
| : R.string.move_preparing); |
| mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up. |
| mProgressBuilder.setContentText(contentText); |
| mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); |
| |
| // Reset batch parameters. |
| mFailedFiles.clear(); |
| mBatchSize = calculateFileSizes(srcs); |
| mBytesCopied = 0; |
| mStartTime = SystemClock.elapsedRealtime(); |
| mLastNotificationTime = 0; |
| mBytesCopiedSample = 0; |
| mSampleTime = 0; |
| mSpeed = 0; |
| mRemainingTime = 0; |
| |
| // TODO: Check preconditions for copy. |
| // - check that the destination has enough space and is writeable? |
| // - check MIME types? |
| } |
| |
| /** |
| * Calculates the cumulative size of all the documents in the list. Directories are recursed |
| * into and totaled up. |
| * |
| * @param srcs |
| * @return Size in bytes. |
| * @throws RemoteException |
| */ |
| private long calculateFileSizes(List<DocumentInfo> srcs) throws RemoteException { |
| long result = 0; |
| for (DocumentInfo src : srcs) { |
| if (Document.MIME_TYPE_DIR.equals(src.mimeType)) { |
| // Directories need to be recursed into. |
| result += calculateFileSizesHelper(src.derivedUri); |
| } else { |
| result += src.size; |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Calculates (recursively) the cumulative size of all the files under the given directory. |
| * |
| * @throws RemoteException |
| */ |
| private long calculateFileSizesHelper(Uri uri) throws RemoteException { |
| final String authority = uri.getAuthority(); |
| final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority, |
| DocumentsContract.getDocumentId(uri)); |
| final String queryColumns[] = new String[] { |
| Document.COLUMN_DOCUMENT_ID, |
| Document.COLUMN_MIME_TYPE, |
| Document.COLUMN_SIZE |
| }; |
| |
| long result = 0; |
| Cursor cursor = null; |
| try { |
| cursor = mSrcClient.query(queryUri, queryColumns, null, null, null); |
| while (cursor.moveToNext()) { |
| if (Document.MIME_TYPE_DIR.equals( |
| getCursorString(cursor, Document.COLUMN_MIME_TYPE))) { |
| // Recurse into directories. |
| final Uri subdirUri = DocumentsContract.buildDocumentUri(authority, |
| getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); |
| result += calculateFileSizesHelper(subdirUri); |
| } else { |
| // This may return -1 if the size isn't defined. Ignore those cases. |
| long size = getCursorLong(cursor, Document.COLUMN_SIZE); |
| result += size > 0 ? size : 0; |
| } |
| } |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| } |
| |
| return result; |
| } |
| |
| /** |
| * Cancels the current copy job, if its ID matches the given ID. |
| * |
| * @param intent The cancellation intent. |
| */ |
| private void handleCancel(Intent intent) { |
| final String cancelledId = intent.getStringExtra(EXTRA_CANCEL); |
| // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey |
| // cancellation requests from affecting unrelated copy jobs. However, if the current job ID |
| // is null, the service most likely crashed and was revived by the incoming cancel intent. |
| // In that case, always allow the cancellation to proceed. |
| if (Objects.equals(mJobId, cancelledId) || mJobId == null) { |
| // Set the cancel flag. This causes the copy loops to exit. |
| mIsCancelled = true; |
| // Dismiss the progress notification here rather than in the copy loop. This preserves |
| // interactivity for the user in case the copy loop is stalled. |
| mNotificationManager.cancel(cancelledId, 0); |
| } |
| } |
| |
| /** |
| * Logs progress on the current copy operation. Displays/Updates the progress notification. |
| * |
| * @param bytesCopied |
| */ |
| private void makeProgress(long bytesCopied) { |
| mBytesCopied += bytesCopied; |
| double done = (double) mBytesCopied / mBatchSize; |
| String percent = NumberFormat.getPercentInstance().format(done); |
| |
| // Update time estimate |
| long currentTime = SystemClock.elapsedRealtime(); |
| long elapsedTime = currentTime - mStartTime; |
| |
| // Send out progress notifications once a second. |
| if (currentTime - mLastNotificationTime > 1000) { |
| updateRemainingTimeEstimate(elapsedTime); |
| mProgressBuilder.setProgress(100, (int) (done * 100), false); |
| mProgressBuilder.setContentInfo(percent); |
| if (mRemainingTime > 0) { |
| mProgressBuilder.setContentText(getString(R.string.copy_remaining, |
| DateUtils.formatDuration(mRemainingTime))); |
| } else { |
| mProgressBuilder.setContentText(null); |
| } |
| mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); |
| mLastNotificationTime = currentTime; |
| } |
| } |
| |
| /** |
| * Generates an estimate of the remaining time in the copy. |
| * |
| * @param elapsedTime The time elapsed so far. |
| */ |
| private void updateRemainingTimeEstimate(long elapsedTime) { |
| final long sampleDuration = elapsedTime - mSampleTime; |
| final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; |
| if (mSpeed == 0) { |
| mSpeed = sampleSpeed; |
| } else { |
| mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; |
| } |
| |
| if (mSampleTime > 0 && mSpeed > 0) { |
| mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed; |
| } else { |
| mRemainingTime = 0; |
| } |
| |
| mSampleTime = elapsedTime; |
| mBytesCopiedSample = mBytesCopied; |
| } |
| |
| /** |
| * Copies a the given documents to the given location. |
| * |
| * @param srcInfo DocumentInfos for the documents to copy. |
| * @param dstDirInfo The destination directory. |
| * @throws RemoteException |
| */ |
| private void copy(DocumentInfo srcInfo, DocumentInfo dstDirInfo, int mode) |
| throws RemoteException { |
| final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirInfo.derivedUri, |
| srcInfo.mimeType, srcInfo.displayName); |
| if (dstUri == null) { |
| // If this is a directory, the entire subdir will not be copied over. |
| Log.e(TAG, "Error while copying " + srcInfo.displayName); |
| mFailedFiles.add(srcInfo); |
| return; |
| } |
| |
| if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) { |
| copyDirectoryHelper(srcInfo.derivedUri, dstUri, mode); |
| } else { |
| copyFileHelper(srcInfo.derivedUri, dstUri, mode); |
| } |
| } |
| |
| /** |
| * Handles recursion into a directory and copying its contents. Note that in linux terms, this |
| * does the equivalent of "cp src/* dst", not "cp -r src dst". |
| * |
| * @param srcDirUri URI of the directory to copy from. The routine will copy the directory's |
| * contents, not the directory itself. |
| * @param dstDirUri URI of the directory to copy to. Must be created beforehand. |
| * @throws RemoteException |
| */ |
| private void copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri, int mode) |
| throws RemoteException { |
| // Recurse into directories. Copy children into the new subdirectory. |
| final String queryColumns[] = new String[] { |
| Document.COLUMN_DISPLAY_NAME, |
| Document.COLUMN_DOCUMENT_ID, |
| Document.COLUMN_MIME_TYPE, |
| Document.COLUMN_SIZE |
| }; |
| final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirUri.getAuthority(), |
| DocumentsContract.getDocumentId(srcDirUri)); |
| Cursor cursor = null; |
| try { |
| // Iterate over srcs in the directory; copy to the destination directory. |
| cursor = mSrcClient.query(queryUri, queryColumns, null, null, null); |
| while (cursor.moveToNext()) { |
| final String childMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); |
| final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri, |
| childMimeType, getCursorString(cursor, Document.COLUMN_DISPLAY_NAME)); |
| final Uri childUri = DocumentsContract.buildDocumentUri(srcDirUri.getAuthority(), |
| getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); |
| if (Document.MIME_TYPE_DIR.equals(childMimeType)) { |
| copyDirectoryHelper(childUri, dstUri, mode); |
| } else { |
| copyFileHelper(childUri, dstUri, mode); |
| } |
| } |
| if (mode == TRANSFER_MODE_MOVE) { |
| try { |
| DocumentsContract.deleteDocument(mSrcClient, srcDirUri); |
| } catch (RemoteException e) { |
| // RemoteExceptions usually signal that the connection is dead, so there's no |
| // point attempting to continue. Propagate the exception up so the copy job is |
| // cancelled. |
| Log.w(TAG, "Failed to clean up after move: " + srcDirUri, e); |
| throw e; |
| } |
| } |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| } |
| } |
| |
| /** |
| * Handles copying a single file. |
| * |
| * @param srcUri URI of the file to copy from. |
| * @param dstUri URI of the *file* to copy to. Must be created beforehand. |
| * @throws RemoteException |
| */ |
| private void copyFileHelper(Uri srcUri, Uri dstUri, int mode) |
| throws RemoteException { |
| // Copy an individual file. |
| CancellationSignal canceller = new CancellationSignal(); |
| ParcelFileDescriptor srcFile = null; |
| ParcelFileDescriptor dstFile = null; |
| InputStream src = null; |
| OutputStream dst = null; |
| |
| IOException copyError = null; |
| try { |
| srcFile = mSrcClient.openFile(srcUri, "r", canceller); |
| dstFile = mDstClient.openFile(dstUri, "w", canceller); |
| src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile); |
| dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile); |
| |
| byte[] buffer = new byte[8192]; |
| int len; |
| while (!mIsCancelled && ((len = src.read(buffer)) != -1)) { |
| dst.write(buffer, 0, len); |
| makeProgress(len); |
| } |
| |
| srcFile.checkError(); |
| } catch (IOException e) { |
| copyError = e; |
| try { |
| dstFile.closeWithError(copyError.getMessage()); |
| } catch (IOException closeError) { |
| Log.e(TAG, "Error closing destination", closeError); |
| } |
| } finally { |
| // This also ensures the file descriptors are closed. |
| IoUtils.closeQuietly(src); |
| IoUtils.closeQuietly(dst); |
| } |
| |
| if (copyError != null) { |
| // Log errors. |
| Log.e(TAG, "Error while copying " + srcUri.toString(), copyError); |
| try { |
| mFailedFiles.add(DocumentInfo.fromUri(getContentResolver(), srcUri)); |
| } catch (FileNotFoundException ignore) { |
| Log.w(TAG, "Source file gone: " + srcUri, copyError); |
| // The source file is gone. |
| } |
| } |
| |
| if (copyError != null || mIsCancelled) { |
| // Clean up half-copied files. |
| canceller.cancel(); |
| try { |
| DocumentsContract.deleteDocument(mDstClient, dstUri); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Failed to clean up after copy error: " + dstUri, e); |
| // RemoteExceptions usually signal that the connection is dead, so there's no point |
| // attempting to continue. Propagate the exception up so the copy job is cancelled. |
| throw e; |
| } |
| } else if (mode == TRANSFER_MODE_MOVE) { |
| // Clean up src files after a successful move. |
| try { |
| DocumentsContract.deleteDocument(mSrcClient, srcUri); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Failed to clean up after move: " + srcUri, e); |
| throw e; |
| } |
| } |
| } |
| } |