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