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