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