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