1 /* 2 * Copyright (C) 2016 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.services; 18 19 import static android.os.SystemClock.elapsedRealtime; 20 import static android.provider.DocumentsContract.buildChildDocumentsUri; 21 import static android.provider.DocumentsContract.buildDocumentUri; 22 import static android.provider.DocumentsContract.getDocumentId; 23 import static android.provider.DocumentsContract.isChildDocument; 24 import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED; 25 import static com.android.documentsui.Shared.DEBUG; 26 import static com.android.documentsui.model.DocumentInfo.getCursorLong; 27 import static com.android.documentsui.model.DocumentInfo.getCursorString; 28 import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; 29 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION; 30 import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST; 31 import static com.android.documentsui.services.FileOperationService.OPERATION_COPY; 32 33 import android.annotation.StringRes; 34 import android.app.Notification; 35 import android.app.Notification.Builder; 36 import android.app.PendingIntent; 37 import android.content.ContentProviderClient; 38 import android.content.Context; 39 import android.content.Intent; 40 import android.content.res.AssetFileDescriptor; 41 import android.database.Cursor; 42 import android.net.Uri; 43 import android.os.CancellationSignal; 44 import android.os.ParcelFileDescriptor; 45 import android.os.RemoteException; 46 import android.provider.DocumentsContract; 47 import android.provider.DocumentsContract.Document; 48 import android.system.ErrnoException; 49 import android.system.Os; 50 import android.text.format.DateUtils; 51 import android.util.Log; 52 import android.webkit.MimeTypeMap; 53 54 import com.android.documentsui.Metrics; 55 import com.android.documentsui.R; 56 import com.android.documentsui.model.DocumentInfo; 57 import com.android.documentsui.model.DocumentStack; 58 import com.android.documentsui.services.FileOperationService.OpType; 59 60 import libcore.io.IoUtils; 61 62 import java.io.FileNotFoundException; 63 import java.io.IOException; 64 import java.io.InputStream; 65 import java.io.OutputStream; 66 import java.text.NumberFormat; 67 import java.util.ArrayList; 68 import java.util.List; 69 70 class CopyJob extends Job { 71 72 private static final String TAG = "CopyJob"; 73 private static final int PROGRESS_INTERVAL_MILLIS = 500; 74 75 final List<DocumentInfo> mSrcs; 76 final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>(); 77 78 private long mStartTime = -1; 79 80 private long mBatchSize; 81 private long mBytesCopied; 82 private long mLastNotificationTime; 83 // Speed estimation 84 private long mBytesCopiedSample; 85 private long mSampleTime; 86 private long mSpeed; 87 private long mRemainingTime; 88 89 /** 90 * Copies files to a destination identified by {@code destination}. 91 * @see @link {@link Job} constructor for most param descriptions. 92 * 93 * @param srcs List of files to be copied. 94 */ 95 CopyJob(Context service, Context appContext, Listener listener, 96 String id, DocumentStack stack, List<DocumentInfo> srcs) { 97 super(service, appContext, listener, OPERATION_COPY, id, stack); 98 99 assert(!srcs.isEmpty()); 100 this.mSrcs = srcs; 101 } 102 103 /** 104 * @see @link {@link Job} constructor for most param descriptions. 105 * 106 * @param srcs List of files to be copied. 107 */ 108 CopyJob(Context service, Context appContext, Listener listener, 109 @OpType int opType, String id, DocumentStack destination, List<DocumentInfo> srcs) { 110 super(service, appContext, listener, opType, id, destination); 111 112 assert(!srcs.isEmpty()); 113 this.mSrcs = srcs; 114 } 115 116 @Override 117 Builder createProgressBuilder() { 118 return super.createProgressBuilder( 119 service.getString(R.string.copy_notification_title), 120 R.drawable.ic_menu_copy, 121 service.getString(android.R.string.cancel), 122 R.drawable.ic_cab_cancel); 123 } 124 125 @Override 126 public Notification getSetupNotification() { 127 return getSetupNotification(service.getString(R.string.copy_preparing)); 128 } 129 130 public boolean shouldUpdateProgress() { 131 // Wait a while between updates :) 132 return elapsedRealtime() - mLastNotificationTime > PROGRESS_INTERVAL_MILLIS; 133 } 134 135 Notification getProgressNotification(@StringRes int msgId) { 136 if (mBatchSize >= 0) { 137 double completed = (double) this.mBytesCopied / mBatchSize; 138 mProgressBuilder.setProgress(100, (int) (completed * 100), false); 139 mProgressBuilder.setContentInfo( 140 NumberFormat.getPercentInstance().format(completed)); 141 } else { 142 // If the total file size failed to compute on some files, then show 143 // an indeterminate spinner. CopyJob would most likely fail on those 144 // files while copying, but would continue with another files. 145 // Also, if the total size is 0 bytes, show an indeterminate spinner. 146 mProgressBuilder.setProgress(0, 0, true); 147 } 148 149 if (mRemainingTime > 0) { 150 mProgressBuilder.setContentText(service.getString(msgId, 151 DateUtils.formatDuration(mRemainingTime))); 152 } else { 153 mProgressBuilder.setContentText(null); 154 } 155 156 // Remember when we last returned progress so we can provide an answer 157 // in shouldUpdateProgress. 158 mLastNotificationTime = elapsedRealtime(); 159 return mProgressBuilder.build(); 160 } 161 162 public Notification getProgressNotification() { 163 return getProgressNotification(R.string.copy_remaining); 164 } 165 166 void onBytesCopied(long numBytes) { 167 this.mBytesCopied += numBytes; 168 } 169 170 /** 171 * Generates an estimate of the remaining time in the copy. 172 */ 173 void updateRemainingTimeEstimate() { 174 long elapsedTime = elapsedRealtime() - mStartTime; 175 176 final long sampleDuration = elapsedTime - mSampleTime; 177 final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; 178 if (mSpeed == 0) { 179 mSpeed = sampleSpeed; 180 } else { 181 mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; 182 } 183 184 if (mSampleTime > 0 && mSpeed > 0) { 185 mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed; 186 } else { 187 mRemainingTime = 0; 188 } 189 190 mSampleTime = elapsedTime; 191 mBytesCopiedSample = mBytesCopied; 192 } 193 194 @Override 195 Notification getFailureNotification() { 196 return getFailureNotification( 197 R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy); 198 } 199 200 @Override 201 Notification getWarningNotification() { 202 final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_WARNING); 203 navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED); 204 navigateIntent.putExtra(EXTRA_OPERATION, operationType); 205 206 navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, convertedFiles); 207 208 // TODO: Consider adding a dialog on tapping the notification with a list of 209 // converted files. 210 final Notification.Builder warningBuilder = new Notification.Builder(service) 211 .setContentTitle(service.getResources().getString( 212 R.string.notification_copy_files_converted_title)) 213 .setContentText(service.getString( 214 R.string.notification_touch_for_details)) 215 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, 216 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) 217 .setCategory(Notification.CATEGORY_ERROR) 218 .setSmallIcon(R.drawable.ic_menu_copy) 219 .setAutoCancel(true); 220 return warningBuilder.build(); 221 } 222 223 @Override 224 void start() { 225 mStartTime = elapsedRealtime(); 226 227 try { 228 mBatchSize = calculateSize(mSrcs); 229 } catch (ResourceException e) { 230 Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); 231 mBatchSize = -1; 232 } 233 234 DocumentInfo srcInfo; 235 DocumentInfo dstInfo = stack.peek(); 236 for (int i = 0; i < mSrcs.size() && !isCanceled(); ++i) { 237 srcInfo = mSrcs.get(i); 238 239 if (DEBUG) Log.d(TAG, 240 "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")" 241 + " to " + dstInfo.displayName + " (" + dstInfo.derivedUri + ")"); 242 243 try { 244 if (dstInfo.equals(srcInfo) || isDescendentOf(srcInfo, dstInfo)) { 245 Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri); 246 onFileFailed(srcInfo); 247 } else { 248 processDocument(srcInfo, null, dstInfo); 249 } 250 } catch (ResourceException e) { 251 Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e); 252 onFileFailed(srcInfo); 253 } 254 } 255 Metrics.logFileOperation(service, operationType, mSrcs, dstInfo); 256 } 257 258 @Override 259 boolean hasWarnings() { 260 return !convertedFiles.isEmpty(); 261 } 262 263 /** 264 * Logs progress on the current copy operation. Displays/Updates the progress notification. 265 * 266 * @param bytesCopied 267 */ 268 private void makeCopyProgress(long bytesCopied) { 269 onBytesCopied(bytesCopied); 270 if (shouldUpdateProgress()) { 271 updateRemainingTimeEstimate(); 272 listener.onProgress(this); 273 } 274 } 275 276 /** 277 * Copies a the given document to the given location. 278 * 279 * @param src DocumentInfos for the documents to copy. 280 * @param srcParent DocumentInfo for the parent of the document to process. 281 * @param dstDirInfo The destination directory. 282 * @throws ResourceException 283 * 284 * TODO: Stop passing srcParent, as it's not used for copy, but for move only. 285 */ 286 void processDocument(DocumentInfo src, DocumentInfo srcParent, 287 DocumentInfo dstDirInfo) throws ResourceException { 288 289 // TODO: When optimized copy kicks in, we'll not making any progress updates. 290 // For now. Local storage isn't using optimized copy. 291 292 // When copying within the same provider, try to use optimized copying. 293 // If not supported, then fallback to byte-by-byte copy/move. 294 if (src.authority.equals(dstDirInfo.authority)) { 295 if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) { 296 try { 297 if (DocumentsContract.copyDocument(getClient(src), src.derivedUri, 298 dstDirInfo.derivedUri) != null) { 299 return; 300 } 301 } catch (RemoteException | RuntimeException e) { 302 Log.e(TAG, "Provider side copy failed for: " + src.derivedUri 303 + " due to an exception.", e); 304 } 305 // If optimized copy fails, then fallback to byte-by-byte copy. 306 if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri); 307 } 308 } 309 310 // If we couldn't do an optimized copy...we fall back to vanilla byte copy. 311 byteCopyDocument(src, dstDirInfo); 312 } 313 314 void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException { 315 final String dstMimeType; 316 final String dstDisplayName; 317 318 if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src); 319 // If the file is virtual, but can be converted to another format, then try to copy it 320 // as such format. Also, append an extension for the target mime type (if known). 321 if (src.isVirtualDocument()) { 322 String[] streamTypes = null; 323 try { 324 streamTypes = getContentResolver().getStreamTypes(src.derivedUri, "*/*"); 325 } catch (RuntimeException e) { 326 throw new ResourceException( 327 "Failed to obtain streamable types for %s due to an exception.", 328 src.derivedUri, e); 329 } 330 if (streamTypes != null && streamTypes.length > 0) { 331 dstMimeType = streamTypes[0]; 332 final String extension = MimeTypeMap.getSingleton(). 333 getExtensionFromMimeType(dstMimeType); 334 dstDisplayName = src.displayName + 335 (extension != null ? "." + extension : src.displayName); 336 } else { 337 throw new ResourceException("Cannot copy virtual file %s. No streamable formats " 338 + "available.", src.derivedUri); 339 } 340 } else { 341 dstMimeType = src.mimeType; 342 dstDisplayName = src.displayName; 343 } 344 345 // Create the target document (either a file or a directory), then copy recursively the 346 // contents (bytes or children). 347 Uri dstUri = null; 348 try { 349 dstUri = DocumentsContract.createDocument( 350 getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName); 351 } catch (RemoteException | RuntimeException e) { 352 throw new ResourceException( 353 "Couldn't create destination document " + dstDisplayName + " in directory %s " 354 + "due to an exception.", dest.derivedUri, e); 355 } 356 if (dstUri == null) { 357 // If this is a directory, the entire subdir will not be copied over. 358 throw new ResourceException( 359 "Couldn't create destination document " + dstDisplayName + " in directory %s.", 360 dest.derivedUri); 361 } 362 363 DocumentInfo dstInfo = null; 364 try { 365 dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri); 366 } catch (FileNotFoundException | RuntimeException e) { 367 throw new ResourceException("Could not load DocumentInfo for newly created file %s.", 368 dstUri); 369 } 370 371 if (Document.MIME_TYPE_DIR.equals(src.mimeType)) { 372 copyDirectoryHelper(src, dstInfo); 373 } else { 374 copyFileHelper(src, dstInfo, dest, dstMimeType); 375 } 376 } 377 378 /** 379 * Handles recursion into a directory and copying its contents. Note that in linux terms, this 380 * does the equivalent of "cp src/* dst", not "cp -r src dst". 381 * 382 * @param srcDir Info of the directory to copy from. The routine will copy the directory's 383 * contents, not the directory itself. 384 * @param destDir Info of the directory to copy to. Must be created beforehand. 385 * @throws ResourceException 386 */ 387 private void copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir) 388 throws ResourceException { 389 // Recurse into directories. Copy children into the new subdirectory. 390 final String queryColumns[] = new String[] { 391 Document.COLUMN_DISPLAY_NAME, 392 Document.COLUMN_DOCUMENT_ID, 393 Document.COLUMN_MIME_TYPE, 394 Document.COLUMN_SIZE, 395 Document.COLUMN_FLAGS 396 }; 397 Cursor cursor = null; 398 boolean success = true; 399 // Iterate over srcs in the directory; copy to the destination directory. 400 final Uri queryUri = buildChildDocumentsUri(srcDir.authority, srcDir.documentId); 401 try { 402 try { 403 cursor = getClient(srcDir).query(queryUri, queryColumns, null, null, null); 404 } catch (RemoteException | RuntimeException e) { 405 throw new ResourceException("Failed to query children of %s due to an exception.", 406 srcDir.derivedUri, e); 407 } 408 409 DocumentInfo src; 410 while (cursor.moveToNext() && !isCanceled()) { 411 try { 412 src = DocumentInfo.fromCursor(cursor, srcDir.authority); 413 processDocument(src, srcDir, destDir); 414 } catch (RuntimeException e) { 415 Log.e(TAG, "Failed to recursively process a file %s due to an exception." 416 .format(srcDir.derivedUri.toString()), e); 417 success = false; 418 } 419 } 420 } catch (RuntimeException e) { 421 Log.e(TAG, "Failed to copy a file %s to %s. " 422 .format(srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e); 423 success = false; 424 } finally { 425 IoUtils.closeQuietly(cursor); 426 } 427 428 if (!success) { 429 throw new RuntimeException("Some files failed to copy during a recursive " 430 + "directory copy."); 431 } 432 } 433 434 /** 435 * Handles copying a single file. 436 * 437 * @param src Info of the file to copy from. 438 * @param dest Info of the *file* to copy to. Must be created beforehand. 439 * @param destParent Info of the parent of the destination. 440 * @param mimeType Mime type for the target. Can be different than source for virtual files. 441 * @throws ResourceException 442 */ 443 private void copyFileHelper(DocumentInfo src, DocumentInfo dest, DocumentInfo destParent, 444 String mimeType) throws ResourceException { 445 CancellationSignal canceller = new CancellationSignal(); 446 AssetFileDescriptor srcFileAsAsset = null; 447 ParcelFileDescriptor srcFile = null; 448 ParcelFileDescriptor dstFile = null; 449 InputStream in = null; 450 ParcelFileDescriptor.AutoCloseOutputStream out = null; 451 boolean success = false; 452 453 try { 454 // If the file is virtual, but can be converted to another format, then try to copy it 455 // as such format. 456 if (src.isVirtualDocument()) { 457 try { 458 srcFileAsAsset = getClient(src).openTypedAssetFileDescriptor( 459 src.derivedUri, mimeType, null, canceller); 460 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 461 throw new ResourceException("Failed to open a file as asset for %s due to an " 462 + "exception.", src.derivedUri, e); 463 } 464 srcFile = srcFileAsAsset.getParcelFileDescriptor(); 465 try { 466 in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset); 467 } catch (IOException e) { 468 throw new ResourceException("Failed to open a file input stream for %s due " 469 + "an exception.", src.derivedUri, e); 470 } 471 } else { 472 try { 473 srcFile = getClient(src).openFile(src.derivedUri, "r", canceller); 474 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 475 throw new ResourceException( 476 "Failed to open a file for %s due to an exception.", src.derivedUri, e); 477 } 478 in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile); 479 } 480 481 try { 482 dstFile = getClient(dest).openFile(dest.derivedUri, "w", canceller); 483 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 484 throw new ResourceException("Failed to open the destination file %s for writing " 485 + "due to an exception.", dest.derivedUri, e); 486 } 487 out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile); 488 489 byte[] buffer = new byte[32 * 1024]; 490 int len; 491 try { 492 while ((len = in.read(buffer)) != -1) { 493 if (isCanceled()) { 494 if (DEBUG) Log.d(TAG, "Canceled copy mid-copy of: " + src.derivedUri); 495 return; 496 } 497 out.write(buffer, 0, len); 498 makeCopyProgress(len); 499 } 500 501 // Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush. 502 IoUtils.close(dstFile.getFileDescriptor()); 503 srcFile.checkError(); 504 } catch (IOException e) { 505 throw new ResourceException( 506 "Failed to copy bytes from %s to %s due to an IO exception.", 507 src.derivedUri, dest.derivedUri, e); 508 } 509 510 if (src.isVirtualDocument()) { 511 convertedFiles.add(src); 512 } 513 514 success = true; 515 } finally { 516 if (!success) { 517 if (dstFile != null) { 518 try { 519 dstFile.closeWithError("Error copying bytes."); 520 } catch (IOException closeError) { 521 Log.w(TAG, "Error closing destination.", closeError); 522 } 523 } 524 525 if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers."); 526 canceller.cancel(); 527 try { 528 deleteDocument(dest, destParent); 529 } catch (ResourceException e) { 530 Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e); 531 } 532 } 533 534 // This also ensures the file descriptors are closed. 535 IoUtils.closeQuietly(in); 536 IoUtils.closeQuietly(out); 537 } 538 } 539 540 /** 541 * Calculates the cumulative size of all the documents in the list. Directories are recursed 542 * into and totaled up. 543 * 544 * @param srcs 545 * @return Size in bytes. 546 * @throws ResourceException 547 */ 548 private long calculateSize(List<DocumentInfo> srcs) throws ResourceException { 549 long result = 0; 550 551 for (DocumentInfo src : srcs) { 552 if (src.isDirectory()) { 553 // Directories need to be recursed into. 554 try { 555 result += calculateFileSizesRecursively(getClient(src), src.derivedUri); 556 } catch (RemoteException e) { 557 throw new ResourceException("Failed to obtain the client for %s.", 558 src.derivedUri); 559 } 560 } else { 561 result += src.size; 562 } 563 } 564 return result; 565 } 566 567 /** 568 * Calculates (recursively) the cumulative size of all the files under the given directory. 569 * 570 * @throws ResourceException 571 */ 572 private static long calculateFileSizesRecursively( 573 ContentProviderClient client, Uri uri) throws ResourceException { 574 final String authority = uri.getAuthority(); 575 final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri)); 576 final String queryColumns[] = new String[] { 577 Document.COLUMN_DOCUMENT_ID, 578 Document.COLUMN_MIME_TYPE, 579 Document.COLUMN_SIZE 580 }; 581 582 long result = 0; 583 Cursor cursor = null; 584 try { 585 cursor = client.query(queryUri, queryColumns, null, null, null); 586 while (cursor.moveToNext()) { 587 if (Document.MIME_TYPE_DIR.equals( 588 getCursorString(cursor, Document.COLUMN_MIME_TYPE))) { 589 // Recurse into directories. 590 final Uri dirUri = buildDocumentUri(authority, 591 getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); 592 result += calculateFileSizesRecursively(client, dirUri); 593 } else { 594 // This may return -1 if the size isn't defined. Ignore those cases. 595 long size = getCursorLong(cursor, Document.COLUMN_SIZE); 596 result += size > 0 ? size : 0; 597 } 598 } 599 } catch (RemoteException | RuntimeException e) { 600 throw new ResourceException( 601 "Failed to calculate size for %s due to an exception.", uri, e); 602 } finally { 603 IoUtils.closeQuietly(cursor); 604 } 605 606 return result; 607 } 608 609 /** 610 * Returns true if {@code doc} is a descendant of {@code parentDoc}. 611 * @throws ResourceException 612 */ 613 boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent) 614 throws ResourceException { 615 if (parent.isDirectory() && doc.authority.equals(parent.authority)) { 616 try { 617 return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri); 618 } catch (RemoteException | RuntimeException e) { 619 throw new ResourceException( 620 "Failed to check if %s is a child of %s due to an exception.", 621 doc.derivedUri, parent.derivedUri, e); 622 } 623 } 624 return false; 625 } 626 627 @Override 628 public String toString() { 629 return new StringBuilder() 630 .append("CopyJob") 631 .append("{") 632 .append("id=" + id) 633 .append(", srcs=" + mSrcs) 634 .append(", destination=" + stack) 635 .append("}") 636 .toString(); 637 } 638 } 639