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