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.base.DocumentInfo.getCursorLong; 26 import static com.android.documentsui.base.DocumentInfo.getCursorString; 27 import static com.android.documentsui.base.Shared.DEBUG; 28 import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; 29 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION_TYPE; 30 import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_DOCS; 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.ContentObserver; 42 import android.database.Cursor; 43 import android.net.Uri; 44 import android.os.CancellationSignal; 45 import android.os.Handler; 46 import android.os.Looper; 47 import android.os.ParcelFileDescriptor; 48 import android.os.RemoteException; 49 import android.provider.DocumentsContract; 50 import android.provider.DocumentsContract.Document; 51 import android.system.ErrnoException; 52 import android.system.Os; 53 import android.system.OsConstants; 54 import android.text.format.DateUtils; 55 import android.util.Log; 56 import android.webkit.MimeTypeMap; 57 58 import com.android.documentsui.DocumentsApplication; 59 import com.android.documentsui.Metrics; 60 import com.android.documentsui.R; 61 import com.android.documentsui.base.DocumentInfo; 62 import com.android.documentsui.base.DocumentStack; 63 import com.android.documentsui.base.Features; 64 import com.android.documentsui.base.RootInfo; 65 import com.android.documentsui.clipping.UrisSupplier; 66 import com.android.documentsui.roots.ProvidersCache; 67 import com.android.documentsui.services.FileOperationService.OpType; 68 69 import libcore.io.IoUtils; 70 71 import java.io.FileNotFoundException; 72 import java.io.IOException; 73 import java.io.InputStream; 74 import java.io.SyncFailedException; 75 import java.text.NumberFormat; 76 import java.util.ArrayList; 77 78 class CopyJob extends ResolvedResourcesJob { 79 80 private static final String TAG = "CopyJob"; 81 82 private static final long LOADING_TIMEOUT = 60000; // 1 min 83 84 final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>(); 85 DocumentInfo mDstInfo; 86 87 private long mStartTime = -1; 88 private long mBytesRequired; 89 private volatile long mBytesCopied; 90 91 // Speed estimation. 92 private long mBytesCopiedSample; 93 private long mSampleTime; 94 private long mSpeed; 95 private long mRemainingTime; 96 97 /** 98 * @see @link {@link Job} constructor for most param descriptions. 99 */ 100 CopyJob(Context service, Listener listener, String id, DocumentStack destination, 101 UrisSupplier srcs, Features features) { 102 this(service, listener, id, OPERATION_COPY, destination, srcs, features); 103 } 104 105 CopyJob(Context service, Listener listener, String id, @OpType int opType, 106 DocumentStack destination, UrisSupplier srcs, Features features) { 107 super(service, listener, id, opType, destination, srcs, features); 108 mDstInfo = destination.peek(); 109 110 assert(srcs.getItemCount() > 0); 111 } 112 113 @Override 114 Builder createProgressBuilder() { 115 return super.createProgressBuilder( 116 service.getString(R.string.copy_notification_title), 117 R.drawable.ic_menu_copy, 118 service.getString(android.R.string.cancel), 119 R.drawable.ic_cab_cancel); 120 } 121 122 @Override 123 public Notification getSetupNotification() { 124 return getSetupNotification(service.getString(R.string.copy_preparing)); 125 } 126 127 Notification getProgressNotification(@StringRes int msgId) { 128 updateRemainingTimeEstimate(); 129 130 if (mBytesRequired >= 0) { 131 double completed = (double) this.mBytesCopied / mBytesRequired; 132 mProgressBuilder.setProgress(100, (int) (completed * 100), false); 133 mProgressBuilder.setSubText( 134 NumberFormat.getPercentInstance().format(completed)); 135 } else { 136 // If the total file size failed to compute on some files, then show 137 // an indeterminate spinner. CopyJob would most likely fail on those 138 // files while copying, but would continue with another files. 139 // Also, if the total size is 0 bytes, show an indeterminate spinner. 140 mProgressBuilder.setProgress(0, 0, true); 141 } 142 143 if (mRemainingTime > 0) { 144 mProgressBuilder.setContentText(service.getString(msgId, 145 DateUtils.formatDuration(mRemainingTime))); 146 } else { 147 mProgressBuilder.setContentText(null); 148 } 149 150 return mProgressBuilder.build(); 151 } 152 153 @Override 154 public Notification getProgressNotification() { 155 return getProgressNotification(R.string.copy_remaining); 156 } 157 158 void onBytesCopied(long numBytes) { 159 this.mBytesCopied += numBytes; 160 } 161 162 /** 163 * Generates an estimate of the remaining time in the copy. 164 */ 165 private void updateRemainingTimeEstimate() { 166 long elapsedTime = elapsedRealtime() - mStartTime; 167 168 // mBytesCopied is modified in worker thread, but this method is called in monitor thread, 169 // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent. 170 final long bytesCopied = mBytesCopied; 171 final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 172 final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; 173 if (mSpeed == 0) { 174 mSpeed = sampleSpeed; 175 } else { 176 mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; 177 } 178 179 if (mSampleTime > 0 && mSpeed > 0) { 180 mRemainingTime = ((mBytesRequired - bytesCopied) * 1000) / mSpeed; 181 } else { 182 mRemainingTime = 0; 183 } 184 185 mSampleTime = elapsedTime; 186 mBytesCopiedSample = bytesCopied; 187 } 188 189 @Override 190 Notification getFailureNotification() { 191 return getFailureNotification( 192 R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy); 193 } 194 195 @Override 196 Notification getWarningNotification() { 197 final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_WARNING); 198 navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED); 199 navigateIntent.putExtra(EXTRA_OPERATION_TYPE, operationType); 200 201 navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_DOCS, convertedFiles); 202 203 // TODO: Consider adding a dialog on tapping the notification with a list of 204 // converted files. 205 final Notification.Builder warningBuilder = createNotificationBuilder() 206 .setContentTitle(service.getResources().getString( 207 R.string.notification_copy_files_converted_title)) 208 .setContentText(service.getString( 209 R.string.notification_touch_for_details)) 210 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, 211 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) 212 .setCategory(Notification.CATEGORY_ERROR) 213 .setSmallIcon(R.drawable.ic_menu_copy) 214 .setAutoCancel(true); 215 return warningBuilder.build(); 216 } 217 218 @Override 219 boolean setUp() { 220 if (!super.setUp()) { 221 return false; 222 } 223 224 // Check if user has canceled this task. 225 if (isCanceled()) { 226 return false; 227 } 228 229 try { 230 mBytesRequired = calculateBytesRequired(); 231 } catch (ResourceException e) { 232 Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); 233 mBytesRequired = -1; 234 } 235 236 // Check if user has canceled this task. We should check it again here as user cancels 237 // tasks in main thread, but this is running in a worker thread. calculateSize() may 238 // take a long time during which user can cancel this task, and we don't want to waste 239 // resources doing useless large chunk of work. 240 if (isCanceled()) { 241 return false; 242 } 243 244 return checkSpace(); 245 } 246 247 @Override 248 void start() { 249 mStartTime = elapsedRealtime(); 250 DocumentInfo srcInfo; 251 for (int i = 0; i < mResolvedDocs.size() && !isCanceled(); ++i) { 252 srcInfo = mResolvedDocs.get(i); 253 254 if (DEBUG) Log.d(TAG, 255 "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")" 256 + " to " + mDstInfo.displayName + " (" + mDstInfo.derivedUri + ")"); 257 258 try { 259 // Copying recursively to itself or one of descendants is not allowed. 260 if (mDstInfo.equals(srcInfo) || isDescendentOf(srcInfo, mDstInfo)) { 261 Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri); 262 onFileFailed(srcInfo); 263 } else { 264 processDocument(srcInfo, null, mDstInfo); 265 } 266 } catch (ResourceException e) { 267 Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e); 268 onFileFailed(srcInfo); 269 } 270 } 271 272 Metrics.logFileOperation(service, operationType, mResolvedDocs, mDstInfo); 273 } 274 275 /** 276 * Checks whether the destination folder has enough space to take all source files. 277 * @return true if the root has enough space or doesn't provide free space info; otherwise false 278 */ 279 boolean checkSpace() { 280 return verifySpaceAvailable(mBytesRequired); 281 } 282 283 /** 284 * Checks whether the destination folder has enough space to take files of batchSize 285 * @param batchSize the total size of files 286 * @return true if the root has enough space or doesn't provide free space info; otherwise false 287 */ 288 final boolean verifySpaceAvailable(long batchSize) { 289 // Default to be true because if batchSize or available space is invalid, we still let the 290 // copy start anyway. 291 boolean available = true; 292 if (batchSize >= 0) { 293 ProvidersCache cache = DocumentsApplication.getProvidersCache(appContext); 294 295 RootInfo root = stack.getRoot(); 296 // Query root info here instead of using stack.root because the number there may be 297 // stale. 298 root = cache.getRootOneshot(root.authority, root.rootId, true); 299 if (root.availableBytes >= 0) { 300 available = (batchSize <= root.availableBytes); 301 } else { 302 Log.w(TAG, root.toString() + " doesn't provide available bytes."); 303 } 304 } 305 306 if (!available) { 307 failureCount = mResolvedDocs.size(); 308 failedDocs.addAll(mResolvedDocs); 309 } 310 311 return available; 312 } 313 314 @Override 315 boolean hasWarnings() { 316 return !convertedFiles.isEmpty(); 317 } 318 319 /** 320 * Logs progress on the current copy operation. Displays/Updates the progress notification. 321 * 322 * @param bytesCopied 323 */ 324 private void makeCopyProgress(long bytesCopied) { 325 onBytesCopied(bytesCopied); 326 } 327 328 /** 329 * Copies a the given document to the given location. 330 * 331 * @param src DocumentInfos for the documents to copy. 332 * @param srcParent DocumentInfo for the parent of the document to process. 333 * @param dstDirInfo The destination directory. 334 * @throws ResourceException 335 * 336 * TODO: Stop passing srcParent, as it's not used for copy, but for move only. 337 */ 338 void processDocument(DocumentInfo src, DocumentInfo srcParent, 339 DocumentInfo dstDirInfo) throws ResourceException { 340 341 // TODO: When optimized copy kicks in, we'll not making any progress updates. 342 // For now. Local storage isn't using optimized copy. 343 344 // When copying within the same provider, try to use optimized copying. 345 // If not supported, then fallback to byte-by-byte copy/move. 346 if (src.authority.equals(dstDirInfo.authority)) { 347 if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) { 348 try { 349 if (DocumentsContract.copyDocument(getClient(src), src.derivedUri, 350 dstDirInfo.derivedUri) != null) { 351 Metrics.logFileOperated( 352 appContext, operationType, Metrics.OPMODE_PROVIDER); 353 return; 354 } 355 } catch (RemoteException | RuntimeException e) { 356 Log.e(TAG, "Provider side copy failed for: " + src.derivedUri 357 + " due to an exception.", e); 358 Metrics.logFileOperationFailure( 359 appContext, Metrics.SUBFILEOP_QUICK_COPY, src.derivedUri); 360 } 361 362 // If optimized copy fails, then fallback to byte-by-byte copy. 363 if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri); 364 } 365 } 366 367 // If we couldn't do an optimized copy...we fall back to vanilla byte copy. 368 byteCopyDocument(src, dstDirInfo); 369 } 370 371 void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException { 372 final String dstMimeType; 373 final String dstDisplayName; 374 375 if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + src); 376 // If the file is virtual, but can be converted to another format, then try to copy it 377 // as such format. Also, append an extension for the target mime type (if known). 378 if (src.isVirtual()) { 379 String[] streamTypes = null; 380 try { 381 streamTypes = getContentResolver().getStreamTypes(src.derivedUri, "*/*"); 382 } catch (RuntimeException e) { 383 Metrics.logFileOperationFailure( 384 appContext, Metrics.SUBFILEOP_OBTAIN_STREAM_TYPE, src.derivedUri); 385 throw new ResourceException( 386 "Failed to obtain streamable types for %s due to an exception.", 387 src.derivedUri, e); 388 } 389 if (streamTypes != null && streamTypes.length > 0) { 390 dstMimeType = streamTypes[0]; 391 final String extension = MimeTypeMap.getSingleton(). 392 getExtensionFromMimeType(dstMimeType); 393 dstDisplayName = src.displayName + 394 (extension != null ? "." + extension : src.displayName); 395 } else { 396 Metrics.logFileOperationFailure( 397 appContext, Metrics.SUBFILEOP_OBTAIN_STREAM_TYPE, src.derivedUri); 398 throw new ResourceException("Cannot copy virtual file %s. No streamable formats " 399 + "available.", src.derivedUri); 400 } 401 } else { 402 dstMimeType = src.mimeType; 403 dstDisplayName = src.displayName; 404 } 405 406 // Create the target document (either a file or a directory), then copy recursively the 407 // contents (bytes or children). 408 Uri dstUri = null; 409 try { 410 dstUri = DocumentsContract.createDocument( 411 getClient(dest), dest.derivedUri, dstMimeType, dstDisplayName); 412 } catch (RemoteException | RuntimeException e) { 413 Metrics.logFileOperationFailure( 414 appContext, Metrics.SUBFILEOP_CREATE_DOCUMENT, dest.derivedUri); 415 throw new ResourceException( 416 "Couldn't create destination document " + dstDisplayName + " in directory %s " 417 + "due to an exception.", dest.derivedUri, e); 418 } 419 if (dstUri == null) { 420 // If this is a directory, the entire subdir will not be copied over. 421 Metrics.logFileOperationFailure( 422 appContext, Metrics.SUBFILEOP_CREATE_DOCUMENT, dest.derivedUri); 423 throw new ResourceException( 424 "Couldn't create destination document " + dstDisplayName + " in directory %s.", 425 dest.derivedUri); 426 } 427 428 DocumentInfo dstInfo = null; 429 try { 430 dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri); 431 } catch (FileNotFoundException | RuntimeException e) { 432 Metrics.logFileOperationFailure( 433 appContext, Metrics.SUBFILEOP_QUERY_DOCUMENT, dstUri); 434 throw new ResourceException("Could not load DocumentInfo for newly created file %s.", 435 dstUri); 436 } 437 438 if (Document.MIME_TYPE_DIR.equals(src.mimeType)) { 439 copyDirectoryHelper(src, dstInfo); 440 } else { 441 copyFileHelper(src, dstInfo, dest, dstMimeType); 442 } 443 } 444 445 /** 446 * Handles recursion into a directory and copying its contents. Note that in linux terms, this 447 * does the equivalent of "cp src/* dst", not "cp -r src dst". 448 * 449 * @param srcDir Info of the directory to copy from. The routine will copy the directory's 450 * contents, not the directory itself. 451 * @param destDir Info of the directory to copy to. Must be created beforehand. 452 * @throws ResourceException 453 */ 454 private void copyDirectoryHelper(DocumentInfo srcDir, DocumentInfo destDir) 455 throws ResourceException { 456 // Recurse into directories. Copy children into the new subdirectory. 457 final String queryColumns[] = new String[] { 458 Document.COLUMN_DISPLAY_NAME, 459 Document.COLUMN_DOCUMENT_ID, 460 Document.COLUMN_MIME_TYPE, 461 Document.COLUMN_SIZE, 462 Document.COLUMN_FLAGS 463 }; 464 Cursor cursor = null; 465 boolean success = true; 466 // Iterate over srcs in the directory; copy to the destination directory. 467 try { 468 try { 469 cursor = queryChildren(srcDir, queryColumns); 470 } catch (RemoteException | RuntimeException e) { 471 Metrics.logFileOperationFailure( 472 appContext, Metrics.SUBFILEOP_QUERY_CHILDREN, srcDir.derivedUri); 473 throw new ResourceException("Failed to query children of %s due to an exception.", 474 srcDir.derivedUri, e); 475 } 476 477 DocumentInfo src; 478 while (cursor.moveToNext() && !isCanceled()) { 479 try { 480 src = DocumentInfo.fromCursor(cursor, srcDir.authority); 481 processDocument(src, srcDir, destDir); 482 } catch (RuntimeException e) { 483 Log.e(TAG, String.format( 484 "Failed to recursively process a file %s due to an exception.", 485 srcDir.derivedUri.toString()), e); 486 success = false; 487 } 488 } 489 } catch (RuntimeException e) { 490 Log.e(TAG, String.format( 491 "Failed to copy a file %s to %s. ", 492 srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e); 493 success = false; 494 } finally { 495 IoUtils.closeQuietly(cursor); 496 } 497 498 if (!success) { 499 throw new RuntimeException("Some files failed to copy during a recursive " 500 + "directory copy."); 501 } 502 } 503 504 /** 505 * Handles copying a single file. 506 * 507 * @param src Info of the file to copy from. 508 * @param dest Info of the *file* to copy to. Must be created beforehand. 509 * @param destParent Info of the parent of the destination. 510 * @param mimeType Mime type for the target. Can be different than source for virtual files. 511 * @throws ResourceException 512 */ 513 private void copyFileHelper(DocumentInfo src, DocumentInfo dest, DocumentInfo destParent, 514 String mimeType) throws ResourceException { 515 CancellationSignal canceller = new CancellationSignal(); 516 AssetFileDescriptor srcFileAsAsset = null; 517 ParcelFileDescriptor srcFile = null; 518 ParcelFileDescriptor dstFile = null; 519 InputStream in = null; 520 ParcelFileDescriptor.AutoCloseOutputStream out = null; 521 boolean success = false; 522 523 try { 524 // If the file is virtual, but can be converted to another format, then try to copy it 525 // as such format. 526 if (src.isVirtual()) { 527 try { 528 srcFileAsAsset = getClient(src).openTypedAssetFileDescriptor( 529 src.derivedUri, mimeType, null, canceller); 530 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 531 Metrics.logFileOperationFailure( 532 appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri); 533 throw new ResourceException("Failed to open a file as asset for %s due to an " 534 + "exception.", src.derivedUri, e); 535 } 536 srcFile = srcFileAsAsset.getParcelFileDescriptor(); 537 try { 538 in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset); 539 } catch (IOException e) { 540 Metrics.logFileOperationFailure( 541 appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri); 542 throw new ResourceException("Failed to open a file input stream for %s due " 543 + "an exception.", src.derivedUri, e); 544 } 545 546 Metrics.logFileOperated( 547 appContext, operationType, Metrics.OPMODE_CONVERTED); 548 } else { 549 try { 550 srcFile = getClient(src).openFile(src.derivedUri, "r", canceller); 551 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 552 Metrics.logFileOperationFailure( 553 appContext, Metrics.SUBFILEOP_OPEN_FILE, src.derivedUri); 554 throw new ResourceException( 555 "Failed to open a file for %s due to an exception.", src.derivedUri, e); 556 } 557 in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile); 558 559 Metrics.logFileOperated( 560 appContext, operationType, Metrics.OPMODE_CONVENTIONAL); 561 } 562 563 try { 564 dstFile = getClient(dest).openFile(dest.derivedUri, "w", canceller); 565 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 566 Metrics.logFileOperationFailure( 567 appContext, Metrics.SUBFILEOP_OPEN_FILE, dest.derivedUri); 568 throw new ResourceException("Failed to open the destination file %s for writing " 569 + "due to an exception.", dest.derivedUri, e); 570 } 571 out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile); 572 573 byte[] buffer = new byte[32 * 1024]; 574 int len; 575 boolean reading = true; 576 try { 577 while ((len = in.read(buffer)) != -1) { 578 if (isCanceled()) { 579 if (DEBUG) Log.d(TAG, "Canceled copy mid-copy of: " + src.derivedUri); 580 return; 581 } 582 reading = false; 583 out.write(buffer, 0, len); 584 makeCopyProgress(len); 585 reading = true; 586 } 587 588 reading = false; 589 // Need to invoke Os#fsync to ensure the file is written to the storage device. 590 try { 591 Os.fsync(dstFile.getFileDescriptor()); 592 } catch (ErrnoException error) { 593 // fsync will fail with fd of pipes and return EROFS or EINVAL. 594 if (error.errno != OsConstants.EROFS && error.errno != OsConstants.EINVAL) { 595 throw new SyncFailedException( 596 "Failed to sync bytes after copying a file."); 597 } 598 } 599 600 // Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush. 601 IoUtils.close(dstFile.getFileDescriptor()); 602 srcFile.checkError(); 603 } catch (IOException e) { 604 Metrics.logFileOperationFailure( 605 appContext, 606 reading ? Metrics.SUBFILEOP_READ_FILE : Metrics.SUBFILEOP_WRITE_FILE, 607 reading ? src.derivedUri: dest.derivedUri); 608 throw new ResourceException( 609 "Failed to copy bytes from %s to %s due to an IO exception.", 610 src.derivedUri, dest.derivedUri, e); 611 } 612 613 if (src.isVirtual()) { 614 convertedFiles.add(src); 615 } 616 617 success = true; 618 } finally { 619 if (!success) { 620 if (dstFile != null) { 621 try { 622 dstFile.closeWithError("Error copying bytes."); 623 } catch (IOException closeError) { 624 Log.w(TAG, "Error closing destination.", closeError); 625 } 626 } 627 628 if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers."); 629 canceller.cancel(); 630 try { 631 deleteDocument(dest, destParent); 632 } catch (ResourceException e) { 633 Log.w(TAG, "Failed to cleanup after copy error: " + src.derivedUri, e); 634 } 635 } 636 637 // This also ensures the file descriptors are closed. 638 IoUtils.closeQuietly(in); 639 IoUtils.closeQuietly(out); 640 } 641 } 642 643 /** 644 * Calculates the cumulative size of all the documents in the list. Directories are recursed 645 * into and totaled up. 646 * 647 * @return Size in bytes. 648 * @throws ResourceException 649 */ 650 private long calculateBytesRequired() throws ResourceException { 651 long result = 0; 652 653 for (DocumentInfo src : mResolvedDocs) { 654 if (src.isDirectory()) { 655 // Directories need to be recursed into. 656 try { 657 result += calculateFileSizesRecursively(getClient(src), src.derivedUri); 658 } catch (RemoteException e) { 659 throw new ResourceException("Failed to obtain the client for %s.", 660 src.derivedUri, e); 661 } 662 } else { 663 result += src.size; 664 } 665 666 if (isCanceled()) { 667 return result; 668 } 669 } 670 return result; 671 } 672 673 /** 674 * Calculates (recursively) the cumulative size of all the files under the given directory. 675 * 676 * @throws ResourceException 677 */ 678 long calculateFileSizesRecursively( 679 ContentProviderClient client, Uri uri) throws ResourceException { 680 final String authority = uri.getAuthority(); 681 final String queryColumns[] = new String[] { 682 Document.COLUMN_DOCUMENT_ID, 683 Document.COLUMN_MIME_TYPE, 684 Document.COLUMN_SIZE 685 }; 686 687 long result = 0; 688 Cursor cursor = null; 689 try { 690 cursor = queryChildren(client, uri, queryColumns); 691 while (cursor.moveToNext() && !isCanceled()) { 692 if (Document.MIME_TYPE_DIR.equals( 693 getCursorString(cursor, Document.COLUMN_MIME_TYPE))) { 694 // Recurse into directories. 695 final Uri dirUri = buildDocumentUri(authority, 696 getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); 697 result += calculateFileSizesRecursively(client, dirUri); 698 } else { 699 // This may return -1 if the size isn't defined. Ignore those cases. 700 long size = getCursorLong(cursor, Document.COLUMN_SIZE); 701 result += size > 0 ? size : 0; 702 } 703 } 704 } catch (RemoteException | RuntimeException e) { 705 throw new ResourceException( 706 "Failed to calculate size for %s due to an exception.", uri, e); 707 } finally { 708 IoUtils.closeQuietly(cursor); 709 } 710 711 return result; 712 } 713 714 /** 715 * Queries children documents. 716 * 717 * SAF allows {@link DocumentsContract#EXTRA_LOADING} in {@link Cursor#getExtras()} to indicate 718 * there are more data to be loaded. Wait until {@link DocumentsContract#EXTRA_LOADING} is 719 * false and then return the cursor. 720 * 721 * @param srcDir the directory whose children are being loading 722 * @param queryColumns columns of metadata to load 723 * @return cursor of all children documents 724 * @throws RemoteException when the remote throws or waiting for update times out 725 */ 726 private Cursor queryChildren(DocumentInfo srcDir, String[] queryColumns) 727 throws RemoteException { 728 try (final ContentProviderClient client = getClient(srcDir)) { 729 return queryChildren(client, srcDir.derivedUri, queryColumns); 730 } 731 } 732 733 /** 734 * Queries children documents. 735 * 736 * SAF allows {@link DocumentsContract#EXTRA_LOADING} in {@link Cursor#getExtras()} to indicate 737 * there are more data to be loaded. Wait until {@link DocumentsContract#EXTRA_LOADING} is 738 * false and then return the cursor. 739 * 740 * @param client the {@link ContentProviderClient} to use to query children 741 * @param dirDocUri the document Uri of the directory whose children are being loaded 742 * @param queryColumns columns of metadata to load 743 * @return cursor of all children documents 744 * @throws RemoteException when the remote throws or waiting for update times out 745 */ 746 private Cursor queryChildren(ContentProviderClient client, Uri dirDocUri, String[] queryColumns) 747 throws RemoteException { 748 // TODO (b/34459983): Optimize this performance by processing partial result first while provider is loading 749 // more data. Note we need to skip size calculation to achieve it. 750 final Uri queryUri = buildChildDocumentsUri(dirDocUri.getAuthority(), getDocumentId(dirDocUri)); 751 Cursor cursor = client.query( 752 queryUri, queryColumns, (String) null, null, null); 753 while (cursor.getExtras().getBoolean(DocumentsContract.EXTRA_LOADING)) { 754 cursor.registerContentObserver(new DirectoryChildrenObserver(queryUri)); 755 try { 756 long start = System.currentTimeMillis(); 757 synchronized (queryUri) { 758 queryUri.wait(LOADING_TIMEOUT); 759 } 760 if (System.currentTimeMillis() - start > LOADING_TIMEOUT) { 761 // Timed out 762 throw new RemoteException("Timed out waiting on update for " + queryUri); 763 } 764 } catch (InterruptedException e) { 765 // Should never happen 766 throw new RuntimeException(e); 767 } 768 769 // Make another query 770 cursor = client.query( 771 queryUri, queryColumns, (String) null, null, null); 772 } 773 774 return cursor; 775 } 776 777 /** 778 * Returns true if {@code doc} is a descendant of {@code parentDoc}. 779 * @throws ResourceException 780 */ 781 boolean isDescendentOf(DocumentInfo doc, DocumentInfo parent) 782 throws ResourceException { 783 if (parent.isDirectory() && doc.authority.equals(parent.authority)) { 784 try { 785 return isChildDocument(getClient(doc), doc.derivedUri, parent.derivedUri); 786 } catch (RemoteException | RuntimeException e) { 787 throw new ResourceException( 788 "Failed to check if %s is a child of %s due to an exception.", 789 doc.derivedUri, parent.derivedUri, e); 790 } 791 } 792 return false; 793 } 794 795 @Override 796 public String toString() { 797 return new StringBuilder() 798 .append("CopyJob") 799 .append("{") 800 .append("id=" + id) 801 .append(", uris=" + mResourceUris) 802 .append(", docs=" + mResolvedDocs) 803 .append(", destination=" + stack) 804 .append("}") 805 .toString(); 806 } 807 808 private static class DirectoryChildrenObserver extends ContentObserver { 809 810 private final Object mNotifier; 811 812 private DirectoryChildrenObserver(Object notifier) { 813 super(new Handler(Looper.getMainLooper())); 814 assert(notifier != null); 815 mNotifier = notifier; 816 } 817 818 @Override 819 public void onChange(boolean selfChange, Uri uri) { 820 synchronized (mNotifier) { 821 mNotifier.notify(); 822 } 823 } 824 } 825 } 826