blob: 8f89b4e6e55ba65140340d475dedd256b373041e [file] [log] [blame]
Steve McKayc83baa02016-01-06 18:32:13 -08001/*
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
17package com.android.documentsui.services;
18
19import static android.os.SystemClock.elapsedRealtime;
20import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow;
21import static com.android.documentsui.Shared.DEBUG;
22import static com.android.documentsui.model.DocumentInfo.getCursorLong;
23import static com.android.documentsui.model.DocumentInfo.getCursorString;
24import static com.android.documentsui.services.FileOperationService.OPERATION_COPY;
25import static com.google.common.base.Preconditions.checkArgument;
26
27import android.annotation.StringRes;
28import android.app.Notification;
29import android.app.Notification.Builder;
30import android.content.ContentProviderClient;
31import android.content.Context;
32import android.content.res.AssetFileDescriptor;
33import android.database.Cursor;
34import android.net.Uri;
35import android.os.CancellationSignal;
36import android.os.ParcelFileDescriptor;
37import android.os.RemoteException;
38import android.provider.DocumentsContract;
39import android.provider.DocumentsContract.Document;
40import android.text.format.DateUtils;
41import android.util.Log;
42import android.webkit.MimeTypeMap;
43
44import com.android.documentsui.R;
45import com.android.documentsui.model.DocumentInfo;
46import com.android.documentsui.model.DocumentStack;
47
48import libcore.io.IoUtils;
49
50import java.io.FileNotFoundException;
51import java.io.IOException;
52import java.io.InputStream;
53import java.io.OutputStream;
54import java.text.NumberFormat;
55import java.util.List;
56
57class CopyJob extends Job {
58 private static final String TAG = "CopyJob";
59 private static final int PROGRESS_INTERVAL_MILLIS = 1000;
60 final List<DocumentInfo> mSrcFiles;
61
62 // Provider clients are acquired for the duration of each copy job. Note that there is an
63 // implicit assumption that all srcs come from the same authority.
64 ContentProviderClient srcClient;
65 ContentProviderClient dstClient;
66
67 private long mStartTime = -1;
68 private long mBatchSize;
69 private long mBytesCopied;
70 private long mLastNotificationTime;
71 // Speed estimation
72 private long mBytesCopiedSample;
73 private long mSampleTime;
74 private long mSpeed;
75 private long mRemainingTime;
76
77 /**
78 * Copies files to a destination identified by {@code destination}.
79 * @see @link {@link Job} constructor for most param descriptions.
80 *
81 * @param srcs List of files to be copied.
82 */
83 CopyJob(Context serviceContext, Context appContext, Listener listener,
84 String id, DocumentStack destination, List<DocumentInfo> srcs) {
85 super(OPERATION_COPY, serviceContext, appContext, listener, id, destination);
86
87 checkArgument(!srcs.isEmpty());
88 this.mSrcFiles = srcs;
89 }
90
91 @Override
92 Builder createProgressBuilder() {
93 return super.createProgressBuilder(
94 serviceContext.getString(R.string.copy_notification_title),
95 R.drawable.ic_menu_copy,
96 serviceContext.getString(android.R.string.cancel),
97 R.drawable.ic_cab_cancel);
98 }
99
100 @Override
101 public Notification getSetupNotification() {
102 return getSetupNotification(serviceContext.getString(R.string.copy_preparing));
103 }
104
105 public boolean shouldUpdateProgress() {
106 // Wait a while between updates :)
107 return elapsedRealtime() - mLastNotificationTime > PROGRESS_INTERVAL_MILLIS;
108 }
109
110 Notification getProgressNotification(@StringRes int msgId) {
111 double completed = (double) this.mBytesCopied / mBatchSize;
112 mProgressBuilder.setProgress(100, (int) (completed * 100), false);
113 mProgressBuilder.setContentInfo(
114 NumberFormat.getPercentInstance().format(completed));
115 if (mRemainingTime > 0) {
116 mProgressBuilder.setContentText(serviceContext.getString(msgId,
117 DateUtils.formatDuration(mRemainingTime)));
118 } else {
119 mProgressBuilder.setContentText(null);
120 }
121
122 // Remember when we last returned progress so we can provide an answer
123 // in shouldUpdateProgress.
124 mLastNotificationTime = elapsedRealtime();
125 return mProgressBuilder.build();
126 }
127
128 public Notification getProgressNotification() {
129 return getProgressNotification(R.string.copy_remaining);
130 }
131
132 void onBytesCopied(long numBytes) {
133 this.mBytesCopied += numBytes;
134 }
135
136 /**
137 * Generates an estimate of the remaining time in the copy.
138 */
139 void updateRemainingTimeEstimate() {
140 long elapsedTime = elapsedRealtime() - mStartTime;
141
142 final long sampleDuration = elapsedTime - mSampleTime;
143 final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
144 if (mSpeed == 0) {
145 mSpeed = sampleSpeed;
146 } else {
147 mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
148 }
149
150 if (mSampleTime > 0 && mSpeed > 0) {
151 mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
152 } else {
153 mRemainingTime = 0;
154 }
155
156 mSampleTime = elapsedTime;
157 mBytesCopiedSample = mBytesCopied;
158 }
159
160 @Override
161 Notification getFailureNotification() {
162 return getFailureNotification(
163 R.plurals.copy_error_notification_title, R.drawable.ic_menu_copy);
164 }
165
166 @Override
167 void run(FileOperationService service) throws RemoteException {
168 mStartTime = elapsedRealtime();
169
170 // Acquire content providers.
171 srcClient = acquireUnstableProviderOrThrow(
172 getContentResolver(),
173 mSrcFiles.get(0).authority);
174 dstClient = acquireUnstableProviderOrThrow(
175 getContentResolver(),
176 stack.peek().authority);
177
178 // client
179 mBatchSize = calculateSize(srcClient, mSrcFiles);
180
181 DocumentInfo srcInfo;
182 DocumentInfo dstInfo;
183 for (int i = 0; i < mSrcFiles.size() && !isCanceled(); ++i) {
184 srcInfo = mSrcFiles.get(i);
185 dstInfo = stack.peek();
186
187 // Guard unsupported recursive operation.
188 if (dstInfo.equals(srcInfo) || isDescendentOf(srcInfo, dstInfo)) {
189 if (DEBUG) Log.d(TAG, "Skipping recursive operation on directory "
190 + dstInfo.derivedUri);
191 onFileFailed(srcInfo);
192 continue;
193 }
194
195 if (DEBUG) Log.d(TAG,
196 "Performing op-type:" + type() + " of " + srcInfo.displayName
197 + " (" + srcInfo.derivedUri + ")" + " to " + dstInfo.displayName
198 + " (" + dstInfo.derivedUri + ")");
199
200 processDocument(srcInfo, dstInfo);
201 }
202 }
203
204 /**
205 * Logs progress on the current copy operation. Displays/Updates the progress notification.
206 *
207 * @param bytesCopied
208 */
209 private void makeCopyProgress(long bytesCopied) {
210 onBytesCopied(bytesCopied);
211 if (shouldUpdateProgress()) {
212 updateRemainingTimeEstimate();
213 listener.onProgress(this);
214 }
215 }
216
217 /**
218 * Copies a the given document to the given location.
219 *
220 * @param srcInfo DocumentInfos for the documents to copy.
221 * @param dstDirInfo The destination directory.
222 * @param mode The transfer mode (copy or move).
223 * @return True on success, false on failure.
224 * @throws RemoteException
225 */
226 boolean processDocument(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException {
227
228 // TODO: When optimized copy kicks in, we'll not making any progress updates.
229 // For now. Local storage isn't using optimized copy.
230
231 // When copying within the same provider, try to use optimized copying and moving.
232 // If not supported, then fallback to byte-by-byte copy/move.
233 if (srcInfo.authority.equals(dstDirInfo.authority)) {
234 if ((srcInfo.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
235 if (DocumentsContract.copyDocument(srcClient, srcInfo.derivedUri,
236 dstDirInfo.derivedUri) == null) {
237 onFileFailed(srcInfo);
238 }
239 return false;
240 }
241 }
242
243 // If we couldn't do an optimized copy...we fall back to vanilla byte copy.
244 return byteCopyDocument(srcInfo, dstDirInfo);
245 }
246
247 boolean byteCopyDocument(DocumentInfo srcInfo, DocumentInfo dstDirInfo)
248 throws RemoteException {
249 final String dstMimeType;
250 final String dstDisplayName;
251
252 // If the file is virtual, but can be converted to another format, then try to copy it
253 // as such format. Also, append an extension for the target mime type (if known).
254 if (srcInfo.isVirtualDocument()) {
255 final String[] streamTypes = getContentResolver().getStreamTypes(
256 srcInfo.derivedUri, "*/*");
257 if (streamTypes != null && streamTypes.length > 0) {
258 dstMimeType = streamTypes[0];
259 final String extension = MimeTypeMap.getSingleton().
260 getExtensionFromMimeType(dstMimeType);
261 dstDisplayName = srcInfo.displayName +
262 (extension != null ? "." + extension : srcInfo.displayName);
263 } else {
264 // The virtual file is not available as any alternative streamable format.
265 // TODO: Log failures.
266 onFileFailed(srcInfo);
267 return false;
268 }
269 } else {
270 dstMimeType = srcInfo.mimeType;
271 dstDisplayName = srcInfo.displayName;
272 }
273
274 // Create the target document (either a file or a directory), then copy recursively the
275 // contents (bytes or children).
276 final Uri dstUri = DocumentsContract.createDocument(dstClient,
277 dstDirInfo.derivedUri, dstMimeType, dstDisplayName);
278 if (dstUri == null) {
279 // If this is a directory, the entire subdir will not be copied over.
280 onFileFailed(srcInfo);
281 return false;
282 }
283
284 DocumentInfo dstInfo = null;
285 try {
286 dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
287 } catch (FileNotFoundException e) {
288 onFileFailed(srcInfo);
289 return false;
290 }
291
292 final boolean success;
293 if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) {
294 success = copyDirectoryHelper(srcInfo, dstInfo);
295 } else {
296 success = copyFileHelper(srcInfo, dstInfo, dstMimeType);
297 }
298
299 return success;
300 }
301
302 /**
303 * Handles recursion into a directory and copying its contents. Note that in linux terms, this
304 * does the equivalent of "cp src/* dst", not "cp -r src dst".
305 *
306 * @param srcDirInfo Info of the directory to copy from. The routine will copy the directory's
307 * contents, not the directory itself.
308 * @param dstDirInfo Info of the directory to copy to. Must be created beforehand.
309 * @return True on success, false if some of the children failed to copy.
310 * @throws RemoteException
311 */
312 private boolean copyDirectoryHelper(DocumentInfo srcDirInfo, DocumentInfo dstDirInfo)
313 throws RemoteException {
314 // Recurse into directories. Copy children into the new subdirectory.
315 final String queryColumns[] = new String[] {
316 Document.COLUMN_DISPLAY_NAME,
317 Document.COLUMN_DOCUMENT_ID,
318 Document.COLUMN_MIME_TYPE,
319 Document.COLUMN_SIZE,
320 Document.COLUMN_FLAGS
321 };
322 Cursor cursor = null;
323 boolean success = true;
324 try {
325 // Iterate over srcs in the directory; copy to the destination directory.
326 final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirInfo.authority,
327 srcDirInfo.documentId);
328 cursor = srcClient.query(queryUri, queryColumns, null, null, null);
329 DocumentInfo srcInfo;
330 while (cursor.moveToNext()) {
331 srcInfo = DocumentInfo.fromCursor(cursor, srcDirInfo.authority);
332 success &= processDocument(srcInfo, dstDirInfo);
333 }
334 } finally {
335 IoUtils.closeQuietly(cursor);
336 }
337
338 return success;
339 }
340
341 /**
342 * Handles copying a single file.
343 *
344 * @param srcUriInfo Info of the file to copy from.
345 * @param dstUriInfo Info of the *file* to copy to. Must be created beforehand.
346 * @param mimeType Mime type for the target. Can be different than source for virtual files.
347 * @return True on success, false on error.
348 * @throws RemoteException
349 */
350 private boolean copyFileHelper(DocumentInfo srcInfo, DocumentInfo dstInfo, String mimeType)
351 throws RemoteException {
352 // Copy an individual file.
353 CancellationSignal canceller = new CancellationSignal();
354 ParcelFileDescriptor srcFile = null;
355 ParcelFileDescriptor dstFile = null;
356 InputStream src = null;
357 OutputStream dst = null;
358
359 boolean success = true;
360 try {
361 // If the file is virtual, but can be converted to another format, then try to copy it
362 // as such format.
363 if (srcInfo.isVirtualDocument()) {
364 final AssetFileDescriptor srcFileAsAsset =
365 srcClient.openTypedAssetFileDescriptor(
366 srcInfo.derivedUri, mimeType, null, canceller);
367 srcFile = srcFileAsAsset.getParcelFileDescriptor();
368 src = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
369 } else {
370 srcFile = srcClient.openFile(srcInfo.derivedUri, "r", canceller);
371 src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
372 }
373
374 dstFile = dstClient.openFile(dstInfo.derivedUri, "w", canceller);
375 dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
376
377 byte[] buffer = new byte[8192];
378 int len;
379 while ((len = src.read(buffer)) != -1) {
380 if (isCanceled()) {
381 if (DEBUG) Log.d(TAG, "Canceled copy mid-copy. Id:" + id);
382 success = false;
383 break;
384 }
385 dst.write(buffer, 0, len);
386 makeCopyProgress(len);
387 }
388
389 srcFile.checkError();
390 } catch (IOException e) {
391 success = false;
392 onFileFailed(srcInfo);
393
394 if (dstFile != null) {
395 try {
396 dstFile.closeWithError(e.getMessage());
397 } catch (IOException closeError) {
398 Log.e(TAG, "Error closing destination", closeError);
399 }
400 }
401 } finally {
402 // This also ensures the file descriptors are closed.
403 IoUtils.closeQuietly(src);
404 IoUtils.closeQuietly(dst);
405 }
406
407 if (!success) {
408 // Clean up half-copied files.
409 canceller.cancel();
410 try {
411 DocumentsContract.deleteDocument(dstClient, dstInfo.derivedUri);
412 } catch (RemoteException e) {
413 // RemoteExceptions usually signal that the connection is dead, so there's no
414 // point attempting to continue. Propagate the exception up so the copy job is
415 // cancelled.
416 Log.w(TAG, "Failed to cleanup after copy error: " + srcInfo.derivedUri, e);
417 throw e;
418 }
419 }
420
421 return success;
422 }
423
424 /**
425 * Calculates the cumulative size of all the documents in the list. Directories are recursed
426 * into and totaled up.
427 *
428 * @param srcs
429 * @return Size in bytes.
430 * @throws RemoteException
431 */
432 private static long calculateSize(ContentProviderClient client, List<DocumentInfo> srcs)
433 throws RemoteException {
434 long result = 0;
435
436 for (DocumentInfo src : srcs) {
437 if (src.isDirectory()) {
438 // Directories need to be recursed into.
439 result += calculateFileSizesRecursively(client, src.derivedUri);
440 } else {
441 result += src.size;
442 }
443 }
444 return result;
445 }
446
447 /**
448 * Calculates (recursively) the cumulative size of all the files under the given directory.
449 *
450 * @throws RemoteException
451 */
452 private static long calculateFileSizesRecursively(
453 ContentProviderClient client, Uri uri) throws RemoteException {
454 final String authority = uri.getAuthority();
455 final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority,
456 DocumentsContract.getDocumentId(uri));
457 final String queryColumns[] = new String[] {
458 Document.COLUMN_DOCUMENT_ID,
459 Document.COLUMN_MIME_TYPE,
460 Document.COLUMN_SIZE
461 };
462
463 long result = 0;
464 Cursor cursor = null;
465 try {
466 cursor = client.query(queryUri, queryColumns, null, null, null);
467 while (cursor.moveToNext()) {
468 if (Document.MIME_TYPE_DIR.equals(
469 getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
470 // Recurse into directories.
471 final Uri dirUri = DocumentsContract.buildDocumentUri(authority,
472 getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
473 result += calculateFileSizesRecursively(client, dirUri);
474 } else {
475 // This may return -1 if the size isn't defined. Ignore those cases.
476 long size = getCursorLong(cursor, Document.COLUMN_SIZE);
477 result += size > 0 ? size : 0;
478 }
479 }
480 } finally {
481 IoUtils.closeQuietly(cursor);
482 }
483
484 return result;
485 }
486
487 @Override
488 void cleanup() {
489 ContentProviderClient.releaseQuietly(srcClient);
490 ContentProviderClient.releaseQuietly(dstClient);
491 }
492
493 /**
494 * Returns true if {@code doc} is a descendant of {@code parentDoc}.
495 * @throws RemoteException
496 */
497 boolean isDescendentOf(DocumentInfo doc, DocumentInfo parentDoc)
498 throws RemoteException {
499 if (parentDoc.isDirectory() && doc.authority.equals(parentDoc.authority)) {
500 return DocumentsContract.isChildDocument(
501 dstClient, doc.derivedUri, parentDoc.derivedUri);
502 }
503 return false;
504 }
505}