1 /* 2 * Copyright (C) 2015 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; 18 19 import static com.android.documentsui.model.DocumentInfo.getCursorLong; 20 import static com.android.documentsui.model.DocumentInfo.getCursorString; 21 22 import android.app.IntentService; 23 import android.app.Notification; 24 import android.app.NotificationManager; 25 import android.app.PendingIntent; 26 import android.content.ContentProviderClient; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.res.Resources; 30 import android.database.Cursor; 31 import android.net.Uri; 32 import android.os.CancellationSignal; 33 import android.os.ParcelFileDescriptor; 34 import android.os.Parcelable; 35 import android.os.RemoteException; 36 import android.os.SystemClock; 37 import android.provider.DocumentsContract; 38 import android.provider.DocumentsContract.Document; 39 import android.text.format.DateUtils; 40 import android.util.Log; 41 import android.widget.Toast; 42 43 import com.android.documentsui.model.DocumentInfo; 44 import com.android.documentsui.model.DocumentStack; 45 46 import libcore.io.IoUtils; 47 48 import java.io.FileNotFoundException; 49 import java.io.IOException; 50 import java.io.InputStream; 51 import java.io.OutputStream; 52 import java.text.NumberFormat; 53 import java.util.ArrayList; 54 import java.util.List; 55 import java.util.Objects; 56 57 public class CopyService extends IntentService { 58 public static final String TAG = "CopyService"; 59 60 private static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; 61 public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; 62 public static final String EXTRA_STACK = "com.android.documentsui.STACK"; 63 public static final String EXTRA_FAILURE = "com.android.documentsui.FAILURE"; 64 65 // TODO: Move it to a shared file when more operations are implemented. 66 public static final int FAILURE_COPY = 1; 67 68 private NotificationManager mNotificationManager; 69 private Notification.Builder mProgressBuilder; 70 71 // Jobs are serialized but a job ID is used, to avoid mixing up cancellation requests. 72 private String mJobId; 73 private volatile boolean mIsCancelled; 74 // Parameters of the copy job. Requests to an IntentService are serialized so this code only 75 // needs to deal with one job at a time. 76 private final ArrayList<DocumentInfo> mFailedFiles; 77 private long mBatchSize; 78 private long mBytesCopied; 79 private long mStartTime; 80 private long mLastNotificationTime; 81 // Speed estimation 82 private long mBytesCopiedSample; 83 private long mSampleTime; 84 private long mSpeed; 85 private long mRemainingTime; 86 // Provider clients are acquired for the duration of each copy job. Note that there is an 87 // implicit assumption that all srcs come from the same authority. 88 private ContentProviderClient mSrcClient; 89 private ContentProviderClient mDstClient; 90 91 public CopyService() { 92 super("CopyService"); 93 94 mFailedFiles = new ArrayList<DocumentInfo>(); 95 } 96 97 /** 98 * Starts the service for a copy operation. 99 * 100 * @param context Context for the intent. 101 * @param srcDocs A list of src files to copy. 102 * @param dstStack The copy destination stack. 103 */ 104 public static void start(Context context, List<DocumentInfo> srcDocs, DocumentStack dstStack) { 105 final Resources res = context.getResources(); 106 final Intent copyIntent = new Intent(context, CopyService.class); 107 copyIntent.putParcelableArrayListExtra( 108 EXTRA_SRC_LIST, new ArrayList<DocumentInfo>(srcDocs)); 109 copyIntent.putExtra(EXTRA_STACK, (Parcelable) dstStack); 110 111 Toast.makeText(context, 112 res.getQuantityString(R.plurals.copy_begin, srcDocs.size(), srcDocs.size()), 113 Toast.LENGTH_SHORT).show(); 114 context.startService(copyIntent); 115 } 116 117 @Override 118 public int onStartCommand(Intent intent, int flags, int startId) { 119 if (intent.hasExtra(EXTRA_CANCEL)) { 120 handleCancel(intent); 121 } 122 return super.onStartCommand(intent, flags, startId); 123 } 124 125 @Override 126 protected void onHandleIntent(Intent intent) { 127 if (intent.hasExtra(EXTRA_CANCEL)) { 128 handleCancel(intent); 129 return; 130 } 131 132 final ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST); 133 final DocumentStack stack = intent.getParcelableExtra(EXTRA_STACK); 134 135 try { 136 // Acquire content providers. 137 mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(), 138 srcs.get(0).authority); 139 mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(), 140 stack.peek().authority); 141 142 setupCopyJob(srcs, stack); 143 144 for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) { 145 copy(srcs.get(i), stack.peek()); 146 } 147 } catch (Exception e) { 148 // Catch-all to prevent any copy errors from wedging the app. 149 Log.e(TAG, "Exceptions occurred during copying", e); 150 } finally { 151 ContentProviderClient.releaseQuietly(mSrcClient); 152 ContentProviderClient.releaseQuietly(mDstClient); 153 154 // Dismiss the ongoing copy notification when the copy is done. 155 mNotificationManager.cancel(mJobId, 0); 156 157 if (mFailedFiles.size() > 0) { 158 final Context context = getApplicationContext(); 159 final Intent navigateIntent = new Intent(context, DocumentsActivity.class); 160 navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack); 161 navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY); 162 navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, mFailedFiles); 163 164 final Notification.Builder errorBuilder = new Notification.Builder(this) 165 .setContentTitle(context.getResources(). 166 getQuantityString(R.plurals.copy_error_notification_title, 167 mFailedFiles.size(), mFailedFiles.size())) 168 .setContentText(getString(R.string.notification_touch_for_details)) 169 .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, 170 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) 171 .setCategory(Notification.CATEGORY_ERROR) 172 .setSmallIcon(R.drawable.ic_menu_copy) 173 .setAutoCancel(true); 174 mNotificationManager.notify(mJobId, 0, errorBuilder.build()); 175 } 176 } 177 } 178 179 @Override 180 public void onCreate() { 181 super.onCreate(); 182 mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); 183 } 184 185 /** 186 * Sets up the CopyService to start tracking and sending notifications for the given batch of 187 * files. 188 * 189 * @param srcs A list of src files to copy. 190 * @param stack The copy destination stack. 191 * @throws RemoteException 192 */ 193 private void setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack) 194 throws RemoteException { 195 // Create an ID for this copy job. Use the timestamp. 196 mJobId = String.valueOf(SystemClock.elapsedRealtime()); 197 // Reset the cancellation flag. 198 mIsCancelled = false; 199 200 final Context context = getApplicationContext(); 201 final Intent navigateIntent = new Intent(context, DocumentsActivity.class); 202 navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack); 203 204 mProgressBuilder = new Notification.Builder(this) 205 .setContentTitle(getString(R.string.copy_notification_title)) 206 .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, 0)) 207 .setCategory(Notification.CATEGORY_PROGRESS) 208 .setSmallIcon(R.drawable.ic_menu_copy) 209 .setOngoing(true); 210 211 final Intent cancelIntent = new Intent(this, CopyService.class); 212 cancelIntent.putExtra(EXTRA_CANCEL, mJobId); 213 mProgressBuilder.addAction(R.drawable.ic_cab_cancel, 214 getString(android.R.string.cancel), PendingIntent.getService(this, 0, 215 cancelIntent, 216 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT)); 217 218 // Send an initial progress notification. 219 mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up. 220 mProgressBuilder.setContentText(getString(R.string.copy_preparing)); 221 mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); 222 223 // Reset batch parameters. 224 mFailedFiles.clear(); 225 mBatchSize = calculateFileSizes(srcs); 226 mBytesCopied = 0; 227 mStartTime = SystemClock.elapsedRealtime(); 228 mLastNotificationTime = 0; 229 mBytesCopiedSample = 0; 230 mSampleTime = 0; 231 mSpeed = 0; 232 mRemainingTime = 0; 233 234 // TODO: Check preconditions for copy. 235 // - check that the destination has enough space and is writeable? 236 // - check MIME types? 237 } 238 239 /** 240 * Calculates the cumulative size of all the documents in the list. Directories are recursed 241 * into and totaled up. 242 * 243 * @param srcs 244 * @return Size in bytes. 245 * @throws RemoteException 246 */ 247 private long calculateFileSizes(List<DocumentInfo> srcs) throws RemoteException { 248 long result = 0; 249 for (DocumentInfo src : srcs) { 250 if (Document.MIME_TYPE_DIR.equals(src.mimeType)) { 251 // Directories need to be recursed into. 252 result += calculateFileSizesHelper(src.derivedUri); 253 } else { 254 result += src.size; 255 } 256 } 257 return result; 258 } 259 260 /** 261 * Calculates (recursively) the cumulative size of all the files under the given directory. 262 * 263 * @throws RemoteException 264 */ 265 private long calculateFileSizesHelper(Uri uri) throws RemoteException { 266 final String authority = uri.getAuthority(); 267 final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority, 268 DocumentsContract.getDocumentId(uri)); 269 final String queryColumns[] = new String[] { 270 Document.COLUMN_DOCUMENT_ID, 271 Document.COLUMN_MIME_TYPE, 272 Document.COLUMN_SIZE 273 }; 274 275 long result = 0; 276 Cursor cursor = null; 277 try { 278 cursor = mSrcClient.query(queryUri, queryColumns, null, null, null); 279 while (cursor.moveToNext()) { 280 if (Document.MIME_TYPE_DIR.equals( 281 getCursorString(cursor, Document.COLUMN_MIME_TYPE))) { 282 // Recurse into directories. 283 final Uri subdirUri = DocumentsContract.buildDocumentUri(authority, 284 getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); 285 result += calculateFileSizesHelper(subdirUri); 286 } else { 287 // This may return -1 if the size isn't defined. Ignore those cases. 288 long size = getCursorLong(cursor, Document.COLUMN_SIZE); 289 result += size > 0 ? size : 0; 290 } 291 } 292 } finally { 293 IoUtils.closeQuietly(cursor); 294 } 295 296 return result; 297 } 298 299 /** 300 * Cancels the current copy job, if its ID matches the given ID. 301 * 302 * @param intent The cancellation intent. 303 */ 304 private void handleCancel(Intent intent) { 305 final String cancelledId = intent.getStringExtra(EXTRA_CANCEL); 306 // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey 307 // cancellation requests from affecting unrelated copy jobs. However, if the current job ID 308 // is null, the service most likely crashed and was revived by the incoming cancel intent. 309 // In that case, always allow the cancellation to proceed. 310 if (Objects.equals(mJobId, cancelledId) || mJobId == null) { 311 // Set the cancel flag. This causes the copy loops to exit. 312 mIsCancelled = true; 313 // Dismiss the progress notification here rather than in the copy loop. This preserves 314 // interactivity for the user in case the copy loop is stalled. 315 mNotificationManager.cancel(cancelledId, 0); 316 } 317 } 318 319 /** 320 * Logs progress on the current copy operation. Displays/Updates the progress notification. 321 * 322 * @param bytesCopied 323 */ 324 private void makeProgress(long bytesCopied) { 325 mBytesCopied += bytesCopied; 326 double done = (double) mBytesCopied / mBatchSize; 327 String percent = NumberFormat.getPercentInstance().format(done); 328 329 // Update time estimate 330 long currentTime = SystemClock.elapsedRealtime(); 331 long elapsedTime = currentTime - mStartTime; 332 333 // Send out progress notifications once a second. 334 if (currentTime - mLastNotificationTime > 1000) { 335 updateRemainingTimeEstimate(elapsedTime); 336 mProgressBuilder.setProgress(100, (int) (done * 100), false); 337 mProgressBuilder.setContentInfo(percent); 338 if (mRemainingTime > 0) { 339 mProgressBuilder.setContentText(getString(R.string.copy_remaining, 340 DateUtils.formatDuration(mRemainingTime))); 341 } else { 342 mProgressBuilder.setContentText(null); 343 } 344 mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); 345 mLastNotificationTime = currentTime; 346 } 347 } 348 349 /** 350 * Generates an estimate of the remaining time in the copy. 351 * 352 * @param elapsedTime The time elapsed so far. 353 */ 354 private void updateRemainingTimeEstimate(long elapsedTime) { 355 final long sampleDuration = elapsedTime - mSampleTime; 356 final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; 357 if (mSpeed == 0) { 358 mSpeed = sampleSpeed; 359 } else { 360 mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; 361 } 362 363 if (mSampleTime > 0 && mSpeed > 0) { 364 mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed; 365 } else { 366 mRemainingTime = 0; 367 } 368 369 mSampleTime = elapsedTime; 370 mBytesCopiedSample = mBytesCopied; 371 } 372 373 /** 374 * Copies a the given documents to the given location. 375 * 376 * @param srcInfo DocumentInfos for the documents to copy. 377 * @param dstDirInfo The destination directory. 378 * @throws RemoteException 379 */ 380 private void copy(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException { 381 final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirInfo.derivedUri, 382 srcInfo.mimeType, srcInfo.displayName); 383 if (dstUri == null) { 384 // If this is a directory, the entire subdir will not be copied over. 385 Log.e(TAG, "Error while copying " + srcInfo.displayName); 386 mFailedFiles.add(srcInfo); 387 return; 388 } 389 390 if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) { 391 copyDirectoryHelper(srcInfo.derivedUri, dstUri); 392 } else { 393 copyFileHelper(srcInfo.derivedUri, dstUri); 394 } 395 } 396 397 /** 398 * Handles recursion into a directory and copying its contents. Note that in linux terms, this 399 * does the equivalent of "cp src/* dst", not "cp -r src dst". 400 * 401 * @param srcDirUri URI of the directory to copy from. The routine will copy the directory's 402 * contents, not the directory itself. 403 * @param dstDirUri URI of the directory to copy to. Must be created beforehand. 404 * @throws RemoteException 405 */ 406 private void copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri) throws RemoteException { 407 // Recurse into directories. Copy children into the new subdirectory. 408 final String queryColumns[] = new String[] { 409 Document.COLUMN_DISPLAY_NAME, 410 Document.COLUMN_DOCUMENT_ID, 411 Document.COLUMN_MIME_TYPE, 412 Document.COLUMN_SIZE 413 }; 414 final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirUri.getAuthority(), 415 DocumentsContract.getDocumentId(srcDirUri)); 416 Cursor cursor = null; 417 try { 418 // Iterate over srcs in the directory; copy to the destination directory. 419 cursor = mSrcClient.query(queryUri, queryColumns, null, null, null); 420 while (cursor.moveToNext()) { 421 final String childMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 422 final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri, 423 childMimeType, getCursorString(cursor, Document.COLUMN_DISPLAY_NAME)); 424 final Uri childUri = DocumentsContract.buildDocumentUri(srcDirUri.getAuthority(), 425 getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)); 426 if (Document.MIME_TYPE_DIR.equals(childMimeType)) { 427 copyDirectoryHelper(childUri, dstUri); 428 } else { 429 copyFileHelper(childUri, dstUri); 430 } 431 } 432 } finally { 433 IoUtils.closeQuietly(cursor); 434 } 435 } 436 437 /** 438 * Handles copying a single file. 439 * 440 * @param srcUri URI of the file to copy from. 441 * @param dstUri URI of the *file* to copy to. Must be created beforehand. 442 * @throws RemoteException 443 */ 444 private void copyFileHelper(Uri srcUri, Uri dstUri) throws RemoteException { 445 // Copy an individual file. 446 CancellationSignal canceller = new CancellationSignal(); 447 ParcelFileDescriptor srcFile = null; 448 ParcelFileDescriptor dstFile = null; 449 InputStream src = null; 450 OutputStream dst = null; 451 452 IOException copyError = null; 453 try { 454 srcFile = mSrcClient.openFile(srcUri, "r", canceller); 455 dstFile = mDstClient.openFile(dstUri, "w", canceller); 456 src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile); 457 dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile); 458 459 byte[] buffer = new byte[8192]; 460 int len; 461 while (!mIsCancelled && ((len = src.read(buffer)) != -1)) { 462 dst.write(buffer, 0, len); 463 makeProgress(len); 464 } 465 466 srcFile.checkError(); 467 } catch (IOException e) { 468 copyError = e; 469 try { 470 dstFile.closeWithError(copyError.getMessage()); 471 } catch (IOException closeError) { 472 Log.e(TAG, "Error closing destination", closeError); 473 } 474 } finally { 475 // This also ensures the file descriptors are closed. 476 IoUtils.closeQuietly(src); 477 IoUtils.closeQuietly(dst); 478 } 479 480 if (copyError != null) { 481 // Log errors. 482 Log.e(TAG, "Error while copying " + srcUri.toString(), copyError); 483 try { 484 mFailedFiles.add(DocumentInfo.fromUri(getContentResolver(), srcUri)); 485 } catch (FileNotFoundException ignore) { 486 Log.w(TAG, "Source file gone: " + srcUri, copyError); 487 // The source file is gone. 488 } 489 } 490 491 if (copyError != null || mIsCancelled) { 492 // Clean up half-copied files. 493 canceller.cancel(); 494 try { 495 DocumentsContract.deleteDocument(mDstClient, dstUri); 496 } catch (RemoteException e) { 497 Log.w(TAG, "Failed to clean up: " + srcUri, e); 498 // RemoteExceptions usually signal that the connection is dead, so there's no point 499 // attempting to continue. Propagate the exception up so the copy job is cancelled. 500 throw e; 501 } 502 } 503 } 504 } 505