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