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