Loading src/com/android/documentsui/services/CopyJob.java +212 −98 Original line number Diff line number Diff line Loading @@ -16,7 +16,6 @@ package com.android.documentsui.services; import static android.os.SystemClock.elapsedRealtime; import static android.provider.DocumentsContract.buildChildDocumentsUri; import static android.provider.DocumentsContract.buildDocumentUri; import static android.provider.DocumentsContract.getDocumentId; Loading Loading @@ -52,6 +51,7 @@ import android.os.Messenger; import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.SystemClock; import android.os.storage.StorageManager; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; Loading @@ -73,6 +73,7 @@ import com.android.documentsui.base.RootInfo; import com.android.documentsui.clipping.UrisSupplier; import com.android.documentsui.roots.ProvidersCache; import com.android.documentsui.services.FileOperationService.OpType; import com.android.internal.annotations.VisibleForTesting; import libcore.io.IoUtils; Loading @@ -83,6 +84,9 @@ import java.io.InputStream; import java.io.SyncFailedException; import java.text.NumberFormat; import java.util.ArrayList; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.LongSupplier; class CopyJob extends ResolvedResourcesJob { Loading @@ -96,15 +100,7 @@ class CopyJob extends ResolvedResourcesJob { private final Handler mHandler = new Handler(Looper.getMainLooper()); private final Messenger mMessenger; private long mStartTime = -1; private long mBytesRequired; private volatile long mBytesCopied; // Speed estimation. private long mBytesCopiedSample; private long mSampleTime; private long mSpeed; private long mRemainingTime; private CopyJobProgressTracker mProgressTracker; /** * @see @link {@link Job} constructor for most param descriptions. Loading Loading @@ -138,28 +134,8 @@ class CopyJob extends ResolvedResourcesJob { } Notification getProgressNotification(@StringRes int msgId) { updateRemainingTimeEstimate(); if (mBytesRequired >= 0) { double completed = (double) this.mBytesCopied / mBytesRequired; mProgressBuilder.setProgress(100, (int) (completed * 100), false); mProgressBuilder.setSubText( NumberFormat.getPercentInstance().format(completed)); } else { // If the total file size failed to compute on some files, then show // an indeterminate spinner. CopyJob would most likely fail on those // files while copying, but would continue with another files. // Also, if the total size is 0 bytes, show an indeterminate spinner. mProgressBuilder.setProgress(0, 0, true); } if (mRemainingTime > 0) { mProgressBuilder.setContentText(service.getString(msgId, DateUtils.formatDuration(mRemainingTime))); } else { mProgressBuilder.setContentText(null); } mProgressTracker.update(mProgressBuilder, (remainingTime) -> service.getString(msgId, DateUtils.formatDuration(remainingTime))); return mProgressBuilder.build(); } Loading @@ -168,10 +144,6 @@ class CopyJob extends ResolvedResourcesJob { return getProgressNotification(R.string.copy_remaining); } void onBytesCopied(long numBytes) { this.mBytesCopied += numBytes; } @Override void finish() { try { Loading @@ -182,33 +154,6 @@ class CopyJob extends ResolvedResourcesJob { super.finish(); } /** * Generates an estimate of the remaining time in the copy. */ private void updateRemainingTimeEstimate() { long elapsedTime = elapsedRealtime() - mStartTime; // mBytesCopied is modified in worker thread, but this method is called in monitor thread, // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent. final long bytesCopied = mBytesCopied; final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; if (mSpeed == 0) { mSpeed = sampleSpeed; } else { mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; } if (mSampleTime > 0 && mSpeed > 0) { mRemainingTime = ((mBytesRequired - bytesCopied) * 1000) / mSpeed; } else { mRemainingTime = 0; } mSampleTime = elapsedTime; mBytesCopiedSample = bytesCopied; } @Override Notification getFailureNotification() { return getFailureNotification( Loading Loading @@ -248,13 +193,7 @@ class CopyJob extends ResolvedResourcesJob { if (isCanceled()) { return false; } try { mBytesRequired = calculateBytesRequired(); } catch (ResourceException e) { Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); mBytesRequired = -1; } mProgressTracker = createProgressTracker(); // Check if user has canceled this task. We should check it again here as user cancels // tasks in main thread, but this is running in a worker thread. calculateSize() may Loading @@ -269,7 +208,8 @@ class CopyJob extends ResolvedResourcesJob { @Override void start() { mStartTime = elapsedRealtime(); mProgressTracker.start(); DocumentInfo srcInfo; for (int i = 0; i < mResolvedDocs.size() && !isCanceled(); ++i) { srcInfo = mResolvedDocs.get(i); Loading @@ -284,7 +224,7 @@ class CopyJob extends ResolvedResourcesJob { Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri); onFileFailed(srcInfo); } else { processDocument(srcInfo, null, mDstInfo); processDocumentThenUpdateProgress(srcInfo, null, mDstInfo); } } catch (ResourceException e) { Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e); Loading @@ -300,7 +240,13 @@ class CopyJob extends ResolvedResourcesJob { * @return true if the root has enough space or doesn't provide free space info; otherwise false */ boolean checkSpace() { return verifySpaceAvailable(mBytesRequired); if (!mProgressTracker.hasRequiredBytes()) { if (DEBUG) Log.w(TAG, "Proceeding copy without knowing required space, files or directories may " + "empty or failed to compute required bytes."); return true; } return verifySpaceAvailable(mProgressTracker.getRequiredBytes()); } /** Loading Loading @@ -345,15 +291,14 @@ class CopyJob extends ResolvedResourcesJob { * @param bytesCopied */ private void makeCopyProgress(long bytesCopied) { final int completed = mBytesRequired >= 0 ? (int) (100.0 * this.mBytesCopied / mBytesRequired) : -1; try { mMessenger.send(Message.obtain(mHandler, MESSAGE_PROGRESS, completed, (int) mRemainingTime)); (int) (100 * mProgressTracker.getProgress()), // Progress in percentage (int) mProgressTracker.getRemainingTimeEstimate())); } catch (RemoteException e) { // Ignore. The frontend may be gone. } onBytesCopied(bytesCopied); mProgressTracker.onBytesCopied(bytesCopied); } /** Loading Loading @@ -399,6 +344,12 @@ class CopyJob extends ResolvedResourcesJob { byteCopyDocument(src, dstDirInfo); } private void processDocumentThenUpdateProgress(DocumentInfo src, DocumentInfo srcParent, DocumentInfo dstDirInfo) throws ResourceException { processDocument(src, srcParent, dstDirInfo); mProgressTracker.onDocumentCompleted(); } void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException { final String dstMimeType; final String dstDisplayName; Loading Loading @@ -679,33 +630,43 @@ class CopyJob extends ResolvedResourcesJob { } /** * Calculates the cumulative size of all the documents in the list. Directories are recursed * into and totaled up. * Create CopyJobProgressTracker instance for notification to update copy progress. * * @return Size in bytes. * @throws ResourceException * @return Instance of CopyJobProgressTracker according required bytes or documents. */ private long calculateBytesRequired() throws ResourceException { long result = 0; private CopyJobProgressTracker createProgressTracker() { long docsRequired = mResolvedDocs.size(); long bytesRequired = 0; try { for (DocumentInfo src : mResolvedDocs) { if (src.isDirectory()) { // Directories need to be recursed into. try { result += calculateFileSizesRecursively(getClient(src), src.derivedUri); bytesRequired += calculateFileSizesRecursively(getClient(src), src.derivedUri); } catch (RemoteException e) { throw new ResourceException("Failed to obtain the client for %s.", src.derivedUri, e); Log.w(TAG, "Failed to obtain the client for " + src.derivedUri, e); return new IndeterminateProgressTracker(bytesRequired); } } else { result += src.size; bytesRequired += src.size; } if (isCanceled()) { return result; break; } } return result; } catch (ResourceException e) { Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); return new IndeterminateProgressTracker(bytesRequired); } if (bytesRequired > 0) { return new ByteCountProgressTracker(bytesRequired, SystemClock::elapsedRealtime); } else { return new FileCountProgressTracker(docsRequired, SystemClock::elapsedRealtime); } } /** Loading Loading @@ -858,4 +819,157 @@ class CopyJob extends ResolvedResourcesJob { } } } @VisibleForTesting static abstract class CopyJobProgressTracker implements ProgressTracker { private LongSupplier mElapsedRealTimeSupplier; // Speed estimation. private long mStartTime = -1; private long mDataProcessedSample; private long mSampleTime; private long mSpeed; private long mRemainingTime = -1; public CopyJobProgressTracker(LongSupplier timeSupplier) { mElapsedRealTimeSupplier = timeSupplier; } protected void onBytesCopied(long numBytes) { } protected void onDocumentCompleted() { } protected boolean hasRequiredBytes() { return false; } protected long getRequiredBytes() { return -1; } protected void start() { mStartTime = mElapsedRealTimeSupplier.getAsLong(); } protected void update(Builder builder, Function<Long, String> messageFormatter) { updateEstimateRemainingTime(); final double completed = getProgress(); builder.setProgress(100, (int) (completed * 100), false); builder.setSubText( NumberFormat.getPercentInstance().format(completed)); if (getRemainingTimeEstimate() > 0) { builder.setContentText(messageFormatter.apply(getRemainingTimeEstimate())); } else { builder.setContentText(null); } } abstract void updateEstimateRemainingTime(); /** * Generates an estimate of the remaining time in the copy. * @param dataProcessed the number of data processed * @param dataRequired the number of data required. */ protected void estimateRemainingTime(final long dataProcessed, final long dataRequired) { final long currentTime = mElapsedRealTimeSupplier.getAsLong(); final long elapsedTime = currentTime - mStartTime; final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 final long sampleSpeed = ((dataProcessed - mDataProcessedSample) * 1000) / sampleDuration; if (mSpeed == 0) { mSpeed = sampleSpeed; } else { mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; } if (mSampleTime > 0 && mSpeed > 0) { mRemainingTime = ((dataRequired - dataProcessed) * 1000) / mSpeed; } mSampleTime = elapsedTime; mDataProcessedSample = dataProcessed; } @Override public long getRemainingTimeEstimate() { return mRemainingTime; } } @VisibleForTesting static class ByteCountProgressTracker extends CopyJobProgressTracker { final long mBytesRequired; final AtomicLong mBytesCopied = new AtomicLong(0); public ByteCountProgressTracker(long bytesRequired, LongSupplier elapsedRealtimeSupplier) { super(elapsedRealtimeSupplier); mBytesRequired = bytesRequired; } @Override public double getProgress() { return (double) mBytesCopied.get() / mBytesRequired; } @Override protected boolean hasRequiredBytes() { return mBytesRequired > 0; } @Override public void onBytesCopied(long numBytes) { mBytesCopied.getAndAdd(numBytes); } @Override public void updateEstimateRemainingTime() { estimateRemainingTime(mBytesCopied.get(), mBytesRequired); } } @VisibleForTesting static class FileCountProgressTracker extends CopyJobProgressTracker { final long mDocsRequired; final AtomicLong mDocsProcessed = new AtomicLong(0); public FileCountProgressTracker(long docsRequired, LongSupplier elapsedRealtimeSupplier) { super(elapsedRealtimeSupplier); mDocsRequired = docsRequired; } @Override public double getProgress() { // Use the number of copied docs to calculate progress when mBytesRequired is zero. return (double) mDocsProcessed.get() / mDocsRequired; } @Override public void onDocumentCompleted() { mDocsProcessed.getAndIncrement(); } @Override public void updateEstimateRemainingTime() { estimateRemainingTime(mDocsProcessed.get(), mDocsRequired); } } private static class IndeterminateProgressTracker extends ByteCountProgressTracker { public IndeterminateProgressTracker(long bytesRequired) { super(bytesRequired, null); } @Override protected void update(Builder builder, Function<Long, String> messageFormatter) { // If the total file size failed to compute on some files, then show // an indeterminate spinner. CopyJob would most likely fail on those // files while copying, but would continue with another files. // Also, if the total size is 0 bytes, show an indeterminate spinner. builder.setProgress(0, 0, true); builder.setContentText(null); } } } src/com/android/documentsui/services/Job.java +10 −0 Original line number Diff line number Diff line Loading @@ -367,4 +367,14 @@ abstract public class Job implements Runnable { void onStart(Job job); void onFinished(Job job); } /** * Interface for tracking job progress. */ interface ProgressTracker { default double getProgress() { return -1; } default long getRemainingTimeEstimate() { return -1; } } } tests/common/com/android/documentsui/services/TestCopyJobProcessTracker.java 0 → 100644 +78 −0 Original line number Diff line number Diff line package com.android.documentsui.services; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import android.app.Notification; import com.android.documentsui.services.CopyJob.CopyJobProgressTracker; import java.util.function.Function; import java.util.function.LongSupplier; class TestCopyJobProcessTracker<T extends CopyJobProgressTracker> { private T mProcessTracker; private Notification.Builder mProgressBuilder; private final Function<Double, String> mProgressFormatter; private final Function<Long, String> mRemainTimeFormatter; private static class TestLongSupplier implements LongSupplier { long mValue = 0; boolean mCalled; @Override public long getAsLong() { mCalled = true; return mValue; } } private TestLongSupplier mTimeSupplier = new TestLongSupplier(); TestCopyJobProcessTracker(Class<T> trackerClass, long requiredData, CopyJob job, Function<Double, String> progressFormatter, Function<Long, String> remainTimeFormatter) throws Exception { mProcessTracker = trackerClass.getDeclaredConstructor(long.class, LongSupplier.class).newInstance(requiredData, mTimeSupplier); mProgressBuilder = job.mProgressBuilder; mProgressFormatter = progressFormatter; mRemainTimeFormatter = remainTimeFormatter; } T getProcessTracker() { return mProcessTracker; } void assertProgressTrackStarted() { assertTrue(mTimeSupplier.mCalled); } void assertStartedProgressEquals(int expectedProgress) { assertEquals(expectedProgress, (int) mProcessTracker.getProgress()); } void assertStartedRemainingTimeEquals(long expectedRemainingTime) { assertEquals(expectedRemainingTime, mProcessTracker.getRemainingTimeEstimate()); } void updateProgressAndRemainingTime(long elapsedTime) { mTimeSupplier.mValue = elapsedTime; mProcessTracker.update(mProgressBuilder, mRemainTimeFormatter); } void assertProgressEquals(double progress) { assertEquals(mProgressFormatter.apply(progress), mProgressBuilder.build().extras.get(Notification.EXTRA_SUB_TEXT)); } void assertReminingTimeEquals(long remainingTime) { assertEquals(mRemainTimeFormatter.apply(remainingTime), mProgressBuilder.build().extras.get(Notification.EXTRA_TEXT)); } void assertNoRemainingTime() { assertNull(mProgressBuilder.build().extras.get(Notification.EXTRA_TEXT)); } } tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java +84 −1 Original line number Diff line number Diff line Loading @@ -18,14 +18,21 @@ package com.android.documentsui.services; import static com.google.common.collect.Lists.newArrayList; import static org.junit.Assert.assertNotEquals; import android.app.Notification; import android.net.Uri; import android.provider.DocumentsContract; import android.test.suitebuilder.annotation.MediumTest; import android.text.format.DateUtils; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.services.FileOperationService.OpType; import java.text.NumberFormat; import java.util.List; import java.util.stream.IntStream; @MediumTest public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJobTest<T> { Loading Loading @@ -84,9 +91,17 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob public void runCopyEmptyDirTest() throws Exception { Uri testDir = mDocs.createFolder(mSrcRoot, "emptyDir"); createJob(newArrayList(testDir)).run(); CopyJob job = createJob(newArrayList(testDir)); job.run(); waitForJobFinished(); Notification progressNotification = job.getProgressNotification(); String copyPercentage = progressNotification.extras.getString(Notification.EXTRA_SUB_TEXT); // the percentage representation should not be NaN. assertNotEquals(copyPercentage.equals(NumberFormat.getPercentInstance().format(Double.NaN)), "Percentage representation should not be NaN."); mDocs.assertChildCount(mDestRoot, 1); mDocs.assertHasDirectory(mDestRoot, "emptyDir"); } Loading Loading @@ -160,6 +175,74 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob mDocs.assertChildCount(mDestRoot, 0); } public void runCopyProgressForFileCountTest() throws Exception { // Init FileCountProgressTracker with 10 docs required to copy. TestCopyJobProcessTracker<CopyJob.FileCountProgressTracker> tracker = new TestCopyJobProcessTracker(CopyJob.FileCountProgressTracker.class, 10, createJob(newArrayList(mDocs.createFolder(mSrcRoot, "dummyDir"))), (completed) -> NumberFormat.getPercentInstance().format(completed), (time) -> mContext.getString(R.string.copy_remaining, DateUtils.formatDuration((Long) time))); // Assert init progress is 0 & default remaining time is -1. tracker.getProcessTracker().start(); tracker.assertProgressTrackStarted(); tracker.assertStartedProgressEquals(0); tracker.assertStartedRemainingTimeEquals(-1); // Progress 20%: 2 docs processed after 1 sec, no remaining time since first sample. IntStream.range(0, 2).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(1000); tracker.assertProgressEquals(0.2); tracker.assertNoRemainingTime(); // Progress 40%: 4 docs processed after 2 secs, expect remaining time is 3 secs. IntStream.range(2, 4).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(2000); tracker.assertProgressEquals(0.4); tracker.assertReminingTimeEquals(3000L); // progress 100%: 10 doc processed after 5 secs, expect no remaining time shown. IntStream.range(4, 10).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(5000); tracker.assertProgressEquals(1.0); tracker.assertNoRemainingTime(); } public void runCopyProgressForByteCountTest() throws Exception { // Init ByteCountProgressTracker with 100 KBytes required to copy. TestCopyJobProcessTracker<CopyJob.ByteCountProgressTracker> tracker = new TestCopyJobProcessTracker(CopyJob.ByteCountProgressTracker.class, 100000, createJob(newArrayList(mDocs.createFolder(mSrcRoot, "dummyDir"))), (completed) -> NumberFormat.getPercentInstance().format(completed), (time) -> mContext.getString(R.string.copy_remaining, DateUtils.formatDuration((Long) time))); // Assert init progress is 0 & default remaining time is -1. tracker.getProcessTracker().start(); tracker.assertProgressTrackStarted(); tracker.assertStartedProgressEquals(0); tracker.assertStartedRemainingTimeEquals(-1); // Progress 25%: 25 KBytes processed after 1 sec, no remaining time since first sample. tracker.getProcessTracker().onBytesCopied(25000); tracker.updateProgressAndRemainingTime(1000); tracker.assertProgressEquals(0.25); tracker.assertNoRemainingTime(); // Progress 50%: 50 KBytes processed after 2 secs, expect remaining time is 2 secs. tracker.getProcessTracker().onBytesCopied(25000); tracker.updateProgressAndRemainingTime(2000); tracker.assertProgressEquals(0.5); tracker.assertReminingTimeEquals(2000L); // Progress 100%: 100 KBytes processed after 4 secs, expect no remaining time shown. tracker.getProcessTracker().onBytesCopied(50000); tracker.updateProgressAndRemainingTime(4000); tracker.assertProgressEquals(1.0); tracker.assertNoRemainingTime(); } void waitForJobFinished() throws Exception { mJobListener.waitForFinished(); mDocs.waitForWrite(); Loading tests/unit/com/android/documentsui/services/CopyJobTest.java +8 −0 Original line number Diff line number Diff line Loading @@ -82,4 +82,12 @@ public class CopyJobTest extends AbstractCopyJobTest<CopyJob> { public void testCopyFileWithReadErrors() throws Exception { runCopyFileWithReadErrorsTest(); } public void testCopyProgressWithFileCount() throws Exception { runCopyProgressForFileCountTest(); } public void testCopyProgressWithByteCount() throws Exception { runCopyProgressForByteCountTest(); } } Loading
src/com/android/documentsui/services/CopyJob.java +212 −98 Original line number Diff line number Diff line Loading @@ -16,7 +16,6 @@ package com.android.documentsui.services; import static android.os.SystemClock.elapsedRealtime; import static android.provider.DocumentsContract.buildChildDocumentsUri; import static android.provider.DocumentsContract.buildDocumentUri; import static android.provider.DocumentsContract.getDocumentId; Loading Loading @@ -52,6 +51,7 @@ import android.os.Messenger; import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.SystemClock; import android.os.storage.StorageManager; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; Loading @@ -73,6 +73,7 @@ import com.android.documentsui.base.RootInfo; import com.android.documentsui.clipping.UrisSupplier; import com.android.documentsui.roots.ProvidersCache; import com.android.documentsui.services.FileOperationService.OpType; import com.android.internal.annotations.VisibleForTesting; import libcore.io.IoUtils; Loading @@ -83,6 +84,9 @@ import java.io.InputStream; import java.io.SyncFailedException; import java.text.NumberFormat; import java.util.ArrayList; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.LongSupplier; class CopyJob extends ResolvedResourcesJob { Loading @@ -96,15 +100,7 @@ class CopyJob extends ResolvedResourcesJob { private final Handler mHandler = new Handler(Looper.getMainLooper()); private final Messenger mMessenger; private long mStartTime = -1; private long mBytesRequired; private volatile long mBytesCopied; // Speed estimation. private long mBytesCopiedSample; private long mSampleTime; private long mSpeed; private long mRemainingTime; private CopyJobProgressTracker mProgressTracker; /** * @see @link {@link Job} constructor for most param descriptions. Loading Loading @@ -138,28 +134,8 @@ class CopyJob extends ResolvedResourcesJob { } Notification getProgressNotification(@StringRes int msgId) { updateRemainingTimeEstimate(); if (mBytesRequired >= 0) { double completed = (double) this.mBytesCopied / mBytesRequired; mProgressBuilder.setProgress(100, (int) (completed * 100), false); mProgressBuilder.setSubText( NumberFormat.getPercentInstance().format(completed)); } else { // If the total file size failed to compute on some files, then show // an indeterminate spinner. CopyJob would most likely fail on those // files while copying, but would continue with another files. // Also, if the total size is 0 bytes, show an indeterminate spinner. mProgressBuilder.setProgress(0, 0, true); } if (mRemainingTime > 0) { mProgressBuilder.setContentText(service.getString(msgId, DateUtils.formatDuration(mRemainingTime))); } else { mProgressBuilder.setContentText(null); } mProgressTracker.update(mProgressBuilder, (remainingTime) -> service.getString(msgId, DateUtils.formatDuration(remainingTime))); return mProgressBuilder.build(); } Loading @@ -168,10 +144,6 @@ class CopyJob extends ResolvedResourcesJob { return getProgressNotification(R.string.copy_remaining); } void onBytesCopied(long numBytes) { this.mBytesCopied += numBytes; } @Override void finish() { try { Loading @@ -182,33 +154,6 @@ class CopyJob extends ResolvedResourcesJob { super.finish(); } /** * Generates an estimate of the remaining time in the copy. */ private void updateRemainingTimeEstimate() { long elapsedTime = elapsedRealtime() - mStartTime; // mBytesCopied is modified in worker thread, but this method is called in monitor thread, // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent. final long bytesCopied = mBytesCopied; final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; if (mSpeed == 0) { mSpeed = sampleSpeed; } else { mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; } if (mSampleTime > 0 && mSpeed > 0) { mRemainingTime = ((mBytesRequired - bytesCopied) * 1000) / mSpeed; } else { mRemainingTime = 0; } mSampleTime = elapsedTime; mBytesCopiedSample = bytesCopied; } @Override Notification getFailureNotification() { return getFailureNotification( Loading Loading @@ -248,13 +193,7 @@ class CopyJob extends ResolvedResourcesJob { if (isCanceled()) { return false; } try { mBytesRequired = calculateBytesRequired(); } catch (ResourceException e) { Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); mBytesRequired = -1; } mProgressTracker = createProgressTracker(); // Check if user has canceled this task. We should check it again here as user cancels // tasks in main thread, but this is running in a worker thread. calculateSize() may Loading @@ -269,7 +208,8 @@ class CopyJob extends ResolvedResourcesJob { @Override void start() { mStartTime = elapsedRealtime(); mProgressTracker.start(); DocumentInfo srcInfo; for (int i = 0; i < mResolvedDocs.size() && !isCanceled(); ++i) { srcInfo = mResolvedDocs.get(i); Loading @@ -284,7 +224,7 @@ class CopyJob extends ResolvedResourcesJob { Log.e(TAG, "Skipping recursive copy of " + srcInfo.derivedUri); onFileFailed(srcInfo); } else { processDocument(srcInfo, null, mDstInfo); processDocumentThenUpdateProgress(srcInfo, null, mDstInfo); } } catch (ResourceException e) { Log.e(TAG, "Failed to copy " + srcInfo.derivedUri, e); Loading @@ -300,7 +240,13 @@ class CopyJob extends ResolvedResourcesJob { * @return true if the root has enough space or doesn't provide free space info; otherwise false */ boolean checkSpace() { return verifySpaceAvailable(mBytesRequired); if (!mProgressTracker.hasRequiredBytes()) { if (DEBUG) Log.w(TAG, "Proceeding copy without knowing required space, files or directories may " + "empty or failed to compute required bytes."); return true; } return verifySpaceAvailable(mProgressTracker.getRequiredBytes()); } /** Loading Loading @@ -345,15 +291,14 @@ class CopyJob extends ResolvedResourcesJob { * @param bytesCopied */ private void makeCopyProgress(long bytesCopied) { final int completed = mBytesRequired >= 0 ? (int) (100.0 * this.mBytesCopied / mBytesRequired) : -1; try { mMessenger.send(Message.obtain(mHandler, MESSAGE_PROGRESS, completed, (int) mRemainingTime)); (int) (100 * mProgressTracker.getProgress()), // Progress in percentage (int) mProgressTracker.getRemainingTimeEstimate())); } catch (RemoteException e) { // Ignore. The frontend may be gone. } onBytesCopied(bytesCopied); mProgressTracker.onBytesCopied(bytesCopied); } /** Loading Loading @@ -399,6 +344,12 @@ class CopyJob extends ResolvedResourcesJob { byteCopyDocument(src, dstDirInfo); } private void processDocumentThenUpdateProgress(DocumentInfo src, DocumentInfo srcParent, DocumentInfo dstDirInfo) throws ResourceException { processDocument(src, srcParent, dstDirInfo); mProgressTracker.onDocumentCompleted(); } void byteCopyDocument(DocumentInfo src, DocumentInfo dest) throws ResourceException { final String dstMimeType; final String dstDisplayName; Loading Loading @@ -679,33 +630,43 @@ class CopyJob extends ResolvedResourcesJob { } /** * Calculates the cumulative size of all the documents in the list. Directories are recursed * into and totaled up. * Create CopyJobProgressTracker instance for notification to update copy progress. * * @return Size in bytes. * @throws ResourceException * @return Instance of CopyJobProgressTracker according required bytes or documents. */ private long calculateBytesRequired() throws ResourceException { long result = 0; private CopyJobProgressTracker createProgressTracker() { long docsRequired = mResolvedDocs.size(); long bytesRequired = 0; try { for (DocumentInfo src : mResolvedDocs) { if (src.isDirectory()) { // Directories need to be recursed into. try { result += calculateFileSizesRecursively(getClient(src), src.derivedUri); bytesRequired += calculateFileSizesRecursively(getClient(src), src.derivedUri); } catch (RemoteException e) { throw new ResourceException("Failed to obtain the client for %s.", src.derivedUri, e); Log.w(TAG, "Failed to obtain the client for " + src.derivedUri, e); return new IndeterminateProgressTracker(bytesRequired); } } else { result += src.size; bytesRequired += src.size; } if (isCanceled()) { return result; break; } } return result; } catch (ResourceException e) { Log.w(TAG, "Failed to calculate total size. Copying without progress.", e); return new IndeterminateProgressTracker(bytesRequired); } if (bytesRequired > 0) { return new ByteCountProgressTracker(bytesRequired, SystemClock::elapsedRealtime); } else { return new FileCountProgressTracker(docsRequired, SystemClock::elapsedRealtime); } } /** Loading Loading @@ -858,4 +819,157 @@ class CopyJob extends ResolvedResourcesJob { } } } @VisibleForTesting static abstract class CopyJobProgressTracker implements ProgressTracker { private LongSupplier mElapsedRealTimeSupplier; // Speed estimation. private long mStartTime = -1; private long mDataProcessedSample; private long mSampleTime; private long mSpeed; private long mRemainingTime = -1; public CopyJobProgressTracker(LongSupplier timeSupplier) { mElapsedRealTimeSupplier = timeSupplier; } protected void onBytesCopied(long numBytes) { } protected void onDocumentCompleted() { } protected boolean hasRequiredBytes() { return false; } protected long getRequiredBytes() { return -1; } protected void start() { mStartTime = mElapsedRealTimeSupplier.getAsLong(); } protected void update(Builder builder, Function<Long, String> messageFormatter) { updateEstimateRemainingTime(); final double completed = getProgress(); builder.setProgress(100, (int) (completed * 100), false); builder.setSubText( NumberFormat.getPercentInstance().format(completed)); if (getRemainingTimeEstimate() > 0) { builder.setContentText(messageFormatter.apply(getRemainingTimeEstimate())); } else { builder.setContentText(null); } } abstract void updateEstimateRemainingTime(); /** * Generates an estimate of the remaining time in the copy. * @param dataProcessed the number of data processed * @param dataRequired the number of data required. */ protected void estimateRemainingTime(final long dataProcessed, final long dataRequired) { final long currentTime = mElapsedRealTimeSupplier.getAsLong(); final long elapsedTime = currentTime - mStartTime; final long sampleDuration = Math.max(elapsedTime - mSampleTime, 1L); // avoid dividing 0 final long sampleSpeed = ((dataProcessed - mDataProcessedSample) * 1000) / sampleDuration; if (mSpeed == 0) { mSpeed = sampleSpeed; } else { mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; } if (mSampleTime > 0 && mSpeed > 0) { mRemainingTime = ((dataRequired - dataProcessed) * 1000) / mSpeed; } mSampleTime = elapsedTime; mDataProcessedSample = dataProcessed; } @Override public long getRemainingTimeEstimate() { return mRemainingTime; } } @VisibleForTesting static class ByteCountProgressTracker extends CopyJobProgressTracker { final long mBytesRequired; final AtomicLong mBytesCopied = new AtomicLong(0); public ByteCountProgressTracker(long bytesRequired, LongSupplier elapsedRealtimeSupplier) { super(elapsedRealtimeSupplier); mBytesRequired = bytesRequired; } @Override public double getProgress() { return (double) mBytesCopied.get() / mBytesRequired; } @Override protected boolean hasRequiredBytes() { return mBytesRequired > 0; } @Override public void onBytesCopied(long numBytes) { mBytesCopied.getAndAdd(numBytes); } @Override public void updateEstimateRemainingTime() { estimateRemainingTime(mBytesCopied.get(), mBytesRequired); } } @VisibleForTesting static class FileCountProgressTracker extends CopyJobProgressTracker { final long mDocsRequired; final AtomicLong mDocsProcessed = new AtomicLong(0); public FileCountProgressTracker(long docsRequired, LongSupplier elapsedRealtimeSupplier) { super(elapsedRealtimeSupplier); mDocsRequired = docsRequired; } @Override public double getProgress() { // Use the number of copied docs to calculate progress when mBytesRequired is zero. return (double) mDocsProcessed.get() / mDocsRequired; } @Override public void onDocumentCompleted() { mDocsProcessed.getAndIncrement(); } @Override public void updateEstimateRemainingTime() { estimateRemainingTime(mDocsProcessed.get(), mDocsRequired); } } private static class IndeterminateProgressTracker extends ByteCountProgressTracker { public IndeterminateProgressTracker(long bytesRequired) { super(bytesRequired, null); } @Override protected void update(Builder builder, Function<Long, String> messageFormatter) { // If the total file size failed to compute on some files, then show // an indeterminate spinner. CopyJob would most likely fail on those // files while copying, but would continue with another files. // Also, if the total size is 0 bytes, show an indeterminate spinner. builder.setProgress(0, 0, true); builder.setContentText(null); } } }
src/com/android/documentsui/services/Job.java +10 −0 Original line number Diff line number Diff line Loading @@ -367,4 +367,14 @@ abstract public class Job implements Runnable { void onStart(Job job); void onFinished(Job job); } /** * Interface for tracking job progress. */ interface ProgressTracker { default double getProgress() { return -1; } default long getRemainingTimeEstimate() { return -1; } } }
tests/common/com/android/documentsui/services/TestCopyJobProcessTracker.java 0 → 100644 +78 −0 Original line number Diff line number Diff line package com.android.documentsui.services; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import android.app.Notification; import com.android.documentsui.services.CopyJob.CopyJobProgressTracker; import java.util.function.Function; import java.util.function.LongSupplier; class TestCopyJobProcessTracker<T extends CopyJobProgressTracker> { private T mProcessTracker; private Notification.Builder mProgressBuilder; private final Function<Double, String> mProgressFormatter; private final Function<Long, String> mRemainTimeFormatter; private static class TestLongSupplier implements LongSupplier { long mValue = 0; boolean mCalled; @Override public long getAsLong() { mCalled = true; return mValue; } } private TestLongSupplier mTimeSupplier = new TestLongSupplier(); TestCopyJobProcessTracker(Class<T> trackerClass, long requiredData, CopyJob job, Function<Double, String> progressFormatter, Function<Long, String> remainTimeFormatter) throws Exception { mProcessTracker = trackerClass.getDeclaredConstructor(long.class, LongSupplier.class).newInstance(requiredData, mTimeSupplier); mProgressBuilder = job.mProgressBuilder; mProgressFormatter = progressFormatter; mRemainTimeFormatter = remainTimeFormatter; } T getProcessTracker() { return mProcessTracker; } void assertProgressTrackStarted() { assertTrue(mTimeSupplier.mCalled); } void assertStartedProgressEquals(int expectedProgress) { assertEquals(expectedProgress, (int) mProcessTracker.getProgress()); } void assertStartedRemainingTimeEquals(long expectedRemainingTime) { assertEquals(expectedRemainingTime, mProcessTracker.getRemainingTimeEstimate()); } void updateProgressAndRemainingTime(long elapsedTime) { mTimeSupplier.mValue = elapsedTime; mProcessTracker.update(mProgressBuilder, mRemainTimeFormatter); } void assertProgressEquals(double progress) { assertEquals(mProgressFormatter.apply(progress), mProgressBuilder.build().extras.get(Notification.EXTRA_SUB_TEXT)); } void assertReminingTimeEquals(long remainingTime) { assertEquals(mRemainTimeFormatter.apply(remainingTime), mProgressBuilder.build().extras.get(Notification.EXTRA_TEXT)); } void assertNoRemainingTime() { assertNull(mProgressBuilder.build().extras.get(Notification.EXTRA_TEXT)); } }
tests/unit/com/android/documentsui/services/AbstractCopyJobTest.java +84 −1 Original line number Diff line number Diff line Loading @@ -18,14 +18,21 @@ package com.android.documentsui.services; import static com.google.common.collect.Lists.newArrayList; import static org.junit.Assert.assertNotEquals; import android.app.Notification; import android.net.Uri; import android.provider.DocumentsContract; import android.test.suitebuilder.annotation.MediumTest; import android.text.format.DateUtils; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.services.FileOperationService.OpType; import java.text.NumberFormat; import java.util.List; import java.util.stream.IntStream; @MediumTest public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJobTest<T> { Loading Loading @@ -84,9 +91,17 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob public void runCopyEmptyDirTest() throws Exception { Uri testDir = mDocs.createFolder(mSrcRoot, "emptyDir"); createJob(newArrayList(testDir)).run(); CopyJob job = createJob(newArrayList(testDir)); job.run(); waitForJobFinished(); Notification progressNotification = job.getProgressNotification(); String copyPercentage = progressNotification.extras.getString(Notification.EXTRA_SUB_TEXT); // the percentage representation should not be NaN. assertNotEquals(copyPercentage.equals(NumberFormat.getPercentInstance().format(Double.NaN)), "Percentage representation should not be NaN."); mDocs.assertChildCount(mDestRoot, 1); mDocs.assertHasDirectory(mDestRoot, "emptyDir"); } Loading Loading @@ -160,6 +175,74 @@ public abstract class AbstractCopyJobTest<T extends CopyJob> extends AbstractJob mDocs.assertChildCount(mDestRoot, 0); } public void runCopyProgressForFileCountTest() throws Exception { // Init FileCountProgressTracker with 10 docs required to copy. TestCopyJobProcessTracker<CopyJob.FileCountProgressTracker> tracker = new TestCopyJobProcessTracker(CopyJob.FileCountProgressTracker.class, 10, createJob(newArrayList(mDocs.createFolder(mSrcRoot, "dummyDir"))), (completed) -> NumberFormat.getPercentInstance().format(completed), (time) -> mContext.getString(R.string.copy_remaining, DateUtils.formatDuration((Long) time))); // Assert init progress is 0 & default remaining time is -1. tracker.getProcessTracker().start(); tracker.assertProgressTrackStarted(); tracker.assertStartedProgressEquals(0); tracker.assertStartedRemainingTimeEquals(-1); // Progress 20%: 2 docs processed after 1 sec, no remaining time since first sample. IntStream.range(0, 2).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(1000); tracker.assertProgressEquals(0.2); tracker.assertNoRemainingTime(); // Progress 40%: 4 docs processed after 2 secs, expect remaining time is 3 secs. IntStream.range(2, 4).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(2000); tracker.assertProgressEquals(0.4); tracker.assertReminingTimeEquals(3000L); // progress 100%: 10 doc processed after 5 secs, expect no remaining time shown. IntStream.range(4, 10).forEach(__ -> tracker.getProcessTracker().onDocumentCompleted()); tracker.updateProgressAndRemainingTime(5000); tracker.assertProgressEquals(1.0); tracker.assertNoRemainingTime(); } public void runCopyProgressForByteCountTest() throws Exception { // Init ByteCountProgressTracker with 100 KBytes required to copy. TestCopyJobProcessTracker<CopyJob.ByteCountProgressTracker> tracker = new TestCopyJobProcessTracker(CopyJob.ByteCountProgressTracker.class, 100000, createJob(newArrayList(mDocs.createFolder(mSrcRoot, "dummyDir"))), (completed) -> NumberFormat.getPercentInstance().format(completed), (time) -> mContext.getString(R.string.copy_remaining, DateUtils.formatDuration((Long) time))); // Assert init progress is 0 & default remaining time is -1. tracker.getProcessTracker().start(); tracker.assertProgressTrackStarted(); tracker.assertStartedProgressEquals(0); tracker.assertStartedRemainingTimeEquals(-1); // Progress 25%: 25 KBytes processed after 1 sec, no remaining time since first sample. tracker.getProcessTracker().onBytesCopied(25000); tracker.updateProgressAndRemainingTime(1000); tracker.assertProgressEquals(0.25); tracker.assertNoRemainingTime(); // Progress 50%: 50 KBytes processed after 2 secs, expect remaining time is 2 secs. tracker.getProcessTracker().onBytesCopied(25000); tracker.updateProgressAndRemainingTime(2000); tracker.assertProgressEquals(0.5); tracker.assertReminingTimeEquals(2000L); // Progress 100%: 100 KBytes processed after 4 secs, expect no remaining time shown. tracker.getProcessTracker().onBytesCopied(50000); tracker.updateProgressAndRemainingTime(4000); tracker.assertProgressEquals(1.0); tracker.assertNoRemainingTime(); } void waitForJobFinished() throws Exception { mJobListener.waitForFinished(); mDocs.waitForWrite(); Loading
tests/unit/com/android/documentsui/services/CopyJobTest.java +8 −0 Original line number Diff line number Diff line Loading @@ -82,4 +82,12 @@ public class CopyJobTest extends AbstractCopyJobTest<CopyJob> { public void testCopyFileWithReadErrors() throws Exception { runCopyFileWithReadErrorsTest(); } public void testCopyProgressWithFileCount() throws Exception { runCopyProgressForFileCountTest(); } public void testCopyProgressWithByteCount() throws Exception { runCopyProgressForByteCountTest(); } }