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