blob: f270d9e21bc70c098d53ab9a07ddc5b8085f6c6d [file] [log] [blame]
/*
* 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.PowerManager;
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";
// TODO: Move it to a shared file when more operations are implemented.
public static final int FAILURE_COPY = 1;
private PowerManager mPowerManager;
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) {
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);
Toast.makeText(context,
res.getQuantityString(R.plurals.copy_begin, 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 PowerManager.WakeLock wakeLock = mPowerManager
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
final ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
final DocumentStack stack = intent.getParcelableExtra(EXTRA_STACK);
try {
wakeLock.acquire();
// Acquire content providers.
mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
srcs.get(0).authority);
mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
stack.peek().authority);
setupCopyJob(srcs, stack);
for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) {
copy(srcs.get(i), stack.peek());
}
} 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);
wakeLock.release();
// 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, DocumentsActivity.class);
navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);
navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY);
navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, mFailedFiles);
final Notification.Builder errorBuilder = new Notification.Builder(this)
.setContentTitle(context.getResources().
getQuantityString(R.plurals.copy_error_notification_title,
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();
mPowerManager = getSystemService(PowerManager.class);
mNotificationManager = getSystemService(NotificationManager.class);
}
/**
* 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.
* @throws RemoteException
*/
private void setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack)
throws RemoteException {
// 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, DocumentsActivity.class);
navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);
mProgressBuilder = new Notification.Builder(this)
.setContentTitle(getString(R.string.copy_notification_title))
.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.
mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up.
mProgressBuilder.setContentText(getString(R.string.copy_preparing));
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) 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);
} else {
copyFileHelper(srcInfo.derivedUri, dstUri);
}
}
/**
* 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) 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);
} else {
copyFileHelper(childUri, dstUri);
}
}
} 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) 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: " + srcUri, 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;
}
}
}
}