Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit cdcf99bc authored by Steve McKay's avatar Steve McKay Committed by Android (Google) Code Review
Browse files

Merge "Support for concurrent file operations."

parents 904ce85e ecbf3c50
Loading
Loading
Loading
Loading
+52 −30
Original line number Diff line number Diff line
@@ -17,6 +17,10 @@
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;
import static android.provider.DocumentsContract.isChildDocument;
import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow;
import static com.android.documentsui.Shared.DEBUG;
import static com.android.documentsui.model.DocumentInfo.getCursorLong;
@@ -44,6 +48,7 @@ import android.webkit.MimeTypeMap;
import com.android.documentsui.R;
import com.android.documentsui.model.DocumentInfo;
import com.android.documentsui.model.DocumentStack;
import com.android.documentsui.services.FileOperationService.OpType;

import libcore.io.IoUtils;

@@ -80,9 +85,22 @@ class CopyJob extends Job {
     *
     * @param srcs List of files to be copied.
     */
    CopyJob(Context serviceContext, Context appContext, Listener listener,
    CopyJob(Context service, Context appContext, Listener listener,
            String id, DocumentStack destination, List<DocumentInfo> srcs) {
        super(OPERATION_COPY, serviceContext, appContext, listener, id, destination);
        super(service, appContext, listener, OPERATION_COPY, id, destination);

        checkArgument(!srcs.isEmpty());
        this.mSrcFiles = srcs;
    }

    /**
     * @see @link {@link Job} constructor for most param descriptions.
     *
     * @param srcs List of files to be copied.
     */
    CopyJob(Context service, Context appContext, Listener listener,
            @OpType int opType, String id, DocumentStack destination, List<DocumentInfo> srcs) {
        super(service, appContext, listener, opType, id, destination);

        checkArgument(!srcs.isEmpty());
        this.mSrcFiles = srcs;
@@ -91,15 +109,15 @@ class CopyJob extends Job {
    @Override
    Builder createProgressBuilder() {
        return super.createProgressBuilder(
                serviceContext.getString(R.string.copy_notification_title),
                service.getString(R.string.copy_notification_title),
                R.drawable.ic_menu_copy,
                serviceContext.getString(android.R.string.cancel),
                service.getString(android.R.string.cancel),
                R.drawable.ic_cab_cancel);
    }

    @Override
    public Notification getSetupNotification() {
        return getSetupNotification(serviceContext.getString(R.string.copy_preparing));
        return getSetupNotification(service.getString(R.string.copy_preparing));
    }

    public boolean shouldUpdateProgress() {
@@ -113,7 +131,7 @@ class CopyJob extends Job {
        mProgressBuilder.setContentInfo(
                NumberFormat.getPercentInstance().format(completed));
        if (mRemainingTime > 0) {
            mProgressBuilder.setContentText(serviceContext.getString(msgId,
            mProgressBuilder.setContentText(service.getString(msgId,
                    DateUtils.formatDuration(mRemainingTime)));
        } else {
            mProgressBuilder.setContentText(null);
@@ -164,7 +182,7 @@ class CopyJob extends Job {
    }

    @Override
    void run(FileOperationService service) throws RemoteException {
    void start() throws RemoteException {
        mStartTime = elapsedRealtime();

        // Acquire content providers.
@@ -186,16 +204,14 @@ class CopyJob extends Job {

            // Guard unsupported recursive operation.
            if (dstInfo.equals(srcInfo) || isDescendentOf(srcInfo, dstInfo)) {
                if (DEBUG) Log.d(TAG, "Skipping recursive operation on directory "
                        + dstInfo.derivedUri);
                onFileFailed(srcInfo);
                onFileFailed(srcInfo,
                        "Skipping recursive operation on directory " + dstInfo.derivedUri + ".");
                continue;
            }

            if (DEBUG) Log.d(TAG,
                    "Performing op-type:" + type() + " of " + srcInfo.displayName
                    + " (" + srcInfo.derivedUri + ")" + " to " + dstInfo.displayName
                    + " (" + dstInfo.derivedUri + ")");
                    "Copying " + srcInfo.displayName + " (" + srcInfo.derivedUri + ")"
                    + " to " + dstInfo.displayName + " (" + dstInfo.derivedUri + ")");

            processDocument(srcInfo, dstInfo);
        }
@@ -219,7 +235,6 @@ class CopyJob extends Job {
     *
     * @param srcInfo DocumentInfos for the documents to copy.
     * @param dstDirInfo The destination directory.
     * @param mode The transfer mode (copy or move).
     * @return True on success, false on failure.
     * @throws RemoteException
     */
@@ -234,7 +249,8 @@ class CopyJob extends Job {
            if ((srcInfo.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
                if (DocumentsContract.copyDocument(srcClient, srcInfo.derivedUri,
                        dstDirInfo.derivedUri) == null) {
                    onFileFailed(srcInfo);
                    onFileFailed(srcInfo,
                            "Provider side copy failed for documents: " + srcInfo.derivedUri + ".");
                }
                return false;
            }
@@ -249,6 +265,7 @@ class CopyJob extends Job {
        final String dstMimeType;
        final String dstDisplayName;

        if (DEBUG) Log.d(TAG, "Doing byte copy of document: " + srcInfo);
        // If the file is virtual, but can be converted to another format, then try to copy it
        // as such format. Also, append an extension for the target mime type (if known).
        if (srcInfo.isVirtualDocument()) {
@@ -261,9 +278,7 @@ class CopyJob extends Job {
                dstDisplayName = srcInfo.displayName +
                        (extension != null ? "." + extension : srcInfo.displayName);
            } else {
                // The virtual file is not available as any alternative streamable format.
                // TODO: Log failures.
                onFileFailed(srcInfo);
                onFileFailed(srcInfo, "Cannot copy virtual file. No streamable formats available.");
                return false;
            }
        } else {
@@ -277,7 +292,9 @@ class CopyJob extends Job {
                dstDirInfo.derivedUri, dstMimeType, dstDisplayName);
        if (dstUri == null) {
            // If this is a directory, the entire subdir will not be copied over.
            onFileFailed(srcInfo);
            onFileFailed(srcInfo,
                    "Couldn't create destination document " + dstDisplayName
                    + " in directory " + dstDirInfo.displayName + ".");
            return false;
        }

@@ -285,7 +302,8 @@ class CopyJob extends Job {
        try {
            dstInfo = DocumentInfo.fromUri(getContentResolver(), dstUri);
        } catch (FileNotFoundException e) {
            onFileFailed(srcInfo);
            onFileFailed(srcInfo,
                    "Could not load DocumentInfo for newly created file: " + dstUri + ".");
            return false;
        }

@@ -327,7 +345,7 @@ class CopyJob extends Job {
                    srcDirInfo.documentId);
            cursor = srcClient.query(queryUri, queryColumns, null, null, null);
            DocumentInfo srcInfo;
            while (cursor.moveToNext()) {
            while (cursor.moveToNext() && !isCanceled()) {
                srcInfo = DocumentInfo.fromCursor(cursor, srcDirInfo.authority);
                success &= processDocument(srcInfo, dstDirInfo);
            }
@@ -374,7 +392,7 @@ class CopyJob extends Job {
            dstFile = dstClient.openFile(dstInfo.derivedUri, "w", canceller);
            dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);

            byte[] buffer = new byte[8192];
            byte[] buffer = new byte[32 * 1024];
            int len;
            while ((len = src.read(buffer)) != -1) {
                if (isCanceled()) {
@@ -389,7 +407,8 @@ class CopyJob extends Job {
            srcFile.checkError();
        } catch (IOException e) {
            success = false;
            onFileFailed(srcInfo);
            onFileFailed(srcInfo, "Exception thrown while copying from "
                    + srcInfo.derivedUri + " to " + dstInfo.derivedUri + ".");

            if (dstFile != null) {
                try {
@@ -405,7 +424,7 @@ class CopyJob extends Job {
        }

        if (!success) {
            // Clean up half-copied files.
            if (DEBUG) Log.d(TAG, "Cleaning up failed operation leftovers.");
            canceller.cancel();
            try {
                DocumentsContract.deleteDocument(dstClient, dstInfo.derivedUri);
@@ -452,8 +471,7 @@ class CopyJob extends Job {
    private static long calculateFileSizesRecursively(
            ContentProviderClient client, Uri uri) throws RemoteException {
        final String authority = uri.getAuthority();
        final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority,
                DocumentsContract.getDocumentId(uri));
        final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri));
        final String queryColumns[] = new String[] {
                Document.COLUMN_DOCUMENT_ID,
                Document.COLUMN_MIME_TYPE,
@@ -468,7 +486,7 @@ class CopyJob extends Job {
                if (Document.MIME_TYPE_DIR.equals(
                        getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
                    // Recurse into directories.
                    final Uri dirUri = DocumentsContract.buildDocumentUri(authority,
                    final Uri dirUri = buildDocumentUri(authority,
                            getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
                    result += calculateFileSizesRecursively(client, dirUri);
                } else {
@@ -497,9 +515,13 @@ class CopyJob extends Job {
    boolean isDescendentOf(DocumentInfo doc, DocumentInfo parentDoc)
            throws RemoteException {
        if (parentDoc.isDirectory() && doc.authority.equals(parentDoc.authority)) {
            return DocumentsContract.isChildDocument(
                    dstClient, doc.derivedUri, parentDoc.derivedUri);
            return isChildDocument(dstClient, doc.derivedUri, parentDoc.derivedUri);
        }
        return false;
    }

    private void onFileFailed(DocumentInfo file, String msg) {
        Log.w(TAG, msg);
        onFileFailed(file);
    }
}
+163 −103
Original line number Diff line number Diff line
@@ -16,36 +16,47 @@

package com.android.documentsui.services;

import static android.os.SystemClock.elapsedRealtime;
import static com.android.documentsui.Shared.DEBUG;
import static com.android.internal.util.Preconditions.checkArgument;
import static com.android.internal.util.Preconditions.checkNotNull;
import static com.android.internal.util.Preconditions.checkState;

import android.annotation.IntDef;
import android.app.IntentService;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.os.PowerManager;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.util.Log;

import com.android.documentsui.Shared;
import com.android.documentsui.model.DocumentInfo;
import com.android.documentsui.model.DocumentStack;

import com.google.common.base.Objects;
import com.android.documentsui.services.Job.Factory;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import javax.annotation.concurrent.GuardedBy;

public class FileOperationService extends Service implements Job.Listener {

    private static final int DEFAULT_DELAY = 0;
    private static final int MAX_DELAY = 10 * 1000;  // ten seconds

public class FileOperationService extends IntentService implements Job.Listener {
    public static final String TAG = "FileOperationService";
    private static final int POOL_SIZE = 2;  // "pool size", not *max* "pool size".

    public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID";
    public static final String EXTRA_DELAY = "com.android.documentsui.DELAY";
    public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
    public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
    public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
@@ -68,23 +79,31 @@ public class FileOperationService extends IntentService implements Job.Listener
    // TODO: Move it to a shared file when more operations are implemented.
    public static final int FAILURE_COPY = 1;

    private PowerManager mPowerManager;
    // The executor and job factory are visible for testing and non-final
    // so we'll have a way to inject test doubles from the test. It's
    // a sub-optimal arrangement.
    @VisibleForTesting ScheduledExecutorService executor;
    @VisibleForTesting Factory jobFactory;

    private PowerManager mPowerManager;
    private PowerManager.WakeLock mWakeLock;  // the wake lock, if held.
    private NotificationManager mNotificationManager;

    // TODO: Rework service to support multiple concurrent jobs.
    private volatile Job mJob;

    // For testing only.
    @Nullable private TestOnlyListener mJobFinishedListener;
    @GuardedBy("mRunning")
    private Map<String, JobRecord> mRunning = new HashMap<>();

    public FileOperationService() {
        super("FileOperationService");
    }
    private int mLastStarted;

    @Override
    public void onCreate() {
        super.onCreate();
        // Allow tests to pre-set these with test doubles.
        if (executor == null) {
            executor = new ScheduledThreadPoolExecutor(POOL_SIZE);
        }

        if (jobFactory == null) {
            jobFactory = Job.Factory.instance;
        }

        if (DEBUG) Log.d(TAG, "Created.");
        mPowerManager = getSystemService(PowerManager.class);
@@ -92,69 +111,50 @@ public class FileOperationService extends IntentService implements Job.Listener
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (DEBUG) Log.d(TAG, "onStartCommand: " + intent);
        if (intent.hasExtra(EXTRA_CANCEL)) {
            handleCancel(intent);
            return START_REDELIVER_INTENT;
        } else {
            return super.onStartCommand(intent, flags, startId);
        }
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        if (DEBUG) Log.d(TAG, "onHandleIntent: " + intent);
    public int onStartCommand(Intent intent, int flags, int startTime) {
        // TODO: Ensure we're not being called with retry or redeliver.
        // checkArgument(flags == 0);  // retry and redeliver are not supported.

        String jobId = intent.getStringExtra(EXTRA_JOB_ID);
        @OpType int operationType = intent.getIntExtra(EXTRA_OPERATION, OPERATION_UNKNOWN);
        checkArgument(jobId != null);

        if (intent.hasExtra(EXTRA_CANCEL)) {
            handleCancel(intent);
            return;
        }

        } else {
            checkArgument(operationType != OPERATION_UNKNOWN);
            handleOperation(intent, startTime, jobId, operationType);
        }

        PowerManager.WakeLock wakeLock = mPowerManager.newWakeLock(
                PowerManager.PARTIAL_WAKE_LOCK, TAG);

        ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
        DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK);

        Job job = createJob(operationType, jobId, srcs, stack);

        try {
            wakeLock.acquire();
        return START_NOT_STICKY;
    }

            mNotificationManager.notify(job.id, 0, job.getSetupNotification());
            job.run(this);
    private void handleOperation(Intent intent, int startTime, String jobId, int operationType) {
        if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with start time " + startTime);

        } catch (Exception e) {
            // Catch-all to prevent any copy errors from wedging the app.
            Log.e(TAG, "Exceptions occurred during copying", e);
        } finally {
            if (DEBUG) Log.d(TAG, "Cleaning up after copy");
        // Track start time so we can stop the service once we're out of work to do.
        mLastStarted = startTime;

            job.cleanup();
            wakeLock.release();
        Job job = null;
        synchronized (mRunning) {
            if (mWakeLock == null) {
                mWakeLock = mPowerManager.newWakeLock(
                        PowerManager.PARTIAL_WAKE_LOCK, TAG);
            }

            // Dismiss the ongoing copy notification when the copy is done.
            mNotificationManager.cancel(job.id, 0);
            List<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
            DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK);

            if (job.failed()) {
                Log.e(TAG, job.failedFiles.size() + " files failed to copy");
                mNotificationManager.notify(job.id, 0, job.getFailureNotification());
            }
            job = createJob(operationType, jobId, srcs, stack);

            // TEST ONLY CODE...<raised eyebrows>
            if (mJobFinishedListener != null) {
                mJobFinishedListener.onFinished(job.failedFiles);
            mWakeLock.acquire();
        }

            deleteJob(job);
            if (DEBUG) Log.d(TAG, "Done cleaning up");
        }
        checkState(job != null);
        int delay = intent.getIntExtra(EXTRA_DELAY, DEFAULT_DELAY);
        checkArgument(delay <= MAX_DELAY);
        ScheduledFuture<?> future = executor.schedule(job, delay, TimeUnit.MILLISECONDS);
        mRunning.put(jobId, new JobRecord(job, future));
    }

    /**
@@ -166,12 +166,25 @@ public class FileOperationService extends IntentService implements Job.Listener
        checkArgument(intent.hasExtra(EXTRA_CANCEL));
        String jobId = checkNotNull(intent.getStringExtra(EXTRA_JOB_ID));

        if (DEBUG) Log.d(TAG, "handleCancel: " + jobId);

        synchronized (mRunning) {
            // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
            // cancellation requests from affecting unrelated copy jobs.  However, if the current job ID
            // is null, the service most likely crashed and was revived by the incoming cancel intent.
            // In that case, always allow the cancellation to proceed.
        if (mJob != null && Objects.equal(jobId, mJob.id)) {
            mJob.cancel();
            JobRecord record = mRunning.get(jobId);
            if (record != null) {
                record.job.cancel();

                // If the job hasn't been started, cancel it and explicitly clean up.
                // If it *has* been started, we wait for it to recognize this, then
                // allow it stop working in an orderly fashion.
                if (record.future.getDelay(TimeUnit.MILLISECONDS) > 0) {
                    record.future.cancel(false);
                    onFinished(record.job);
                }
            }
        }

        // Dismiss the progress notification here rather than in the copy loop. This preserves
@@ -179,24 +192,23 @@ public class FileOperationService extends IntentService implements Job.Listener
        // Try to cancel it even if we don't have a job id...in case there is some sad
        // orphan notification.
        mNotificationManager.cancel(jobId, 0);
    }

    public static String createJobId() {
        return String.valueOf(elapsedRealtime());
        // TODO: Guarantee the job is being finalized
    }

    Job createJob(
            @OpType int operationType, String id, ArrayList<DocumentInfo> srcs,
            DocumentStack stack) {
    @GuardedBy("mRunning")
    private Job createJob(
            @OpType int operationType, String id, List<DocumentInfo> srcs, DocumentStack stack) {

        checkState(mJob == null);
        checkArgument(!mRunning.containsKey(id));

        Job job = null;
        switch (operationType) {
            case OPERATION_COPY:
                mJob = new CopyJob(this, getApplicationContext(), this, id, stack, srcs);
                job = jobFactory.createCopy(this, getApplicationContext(), this, id, stack, srcs);
                break;
            case OPERATION_MOVE:
                mJob = new MoveJob(this, getApplicationContext(), this, id, stack, srcs);
                job = jobFactory.createMove(this, getApplicationContext(), this, id, stack, srcs);
                break;
            case OPERATION_DELETE:
                throw new UnsupportedOperationException();
@@ -204,42 +216,90 @@ public class FileOperationService extends IntentService implements Job.Listener
                throw new UnsupportedOperationException();
        }

        return checkNotNull(mJob);
        return checkNotNull(job);
    }

    @GuardedBy("mRunning")
    private void deleteJob(Job job) {
        if (DEBUG) Log.d(TAG, "deleteJob: " + job.id);

        JobRecord record = mRunning.remove(job.id);
        checkArgument(record != null);
        record.job.cleanup();

        if (mRunning.isEmpty()) {
            shutdown();
        }
    }

    /**
     * Most likely shuts down. Won't shut down if service has a pending
     * message.
     */
    private void shutdown() {
        if (DEBUG) Log.d(TAG, "Shutting down. Last start time: " + mLastStarted);
        mWakeLock.release();
        mWakeLock = null;
        boolean gonnaStop = stopSelfResult(mLastStarted);
        if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop);
        if (!gonnaStop) {
            Log.w(TAG, "Service should be stopping, but reports otherwise.");
        }
        // Sadly "gonnaStop" is always false in tests, so we can't guard executor shutdown.
        List<Runnable> unfinished = executor.shutdownNow();
        checkState(unfinished.isEmpty());
    }

    @VisibleForTesting
    boolean holdsWakeLock() {
        return mWakeLock != null && mWakeLock.isHeld();
    }

    void deleteJob(Job job) {
        checkArgument(job == mJob);
        mJob = null;
    @Override
    public void onStart(Job job) {
        if (DEBUG) Log.d(TAG, "onStart: " + job.id);
        mNotificationManager.notify(job.id, 0, job.getSetupNotification());
    }

    @Override
    public void onFinished(Job job) {
        if (DEBUG) Log.d(TAG, "onFinished: " + job.id);

        // Dismiss the ongoing copy notification when the copy is done.
        mNotificationManager.cancel(job.id, 0);

        synchronized (mRunning) {
            deleteJob(job);
        }
    }

    @Override
    public void onProgress(CopyJob job) {
        if (DEBUG) Log.d(TAG, "On copy progress...");
        if (DEBUG) Log.d(TAG, "onProgress: " + job.id);
        mNotificationManager.notify(job.id, 0, job.getProgressNotification());
    }

    @Override
    public void onProgress(MoveJob job) {
        if (DEBUG) Log.d(TAG, "On move progress...");
        mNotificationManager.notify(job.id, 0, job.getProgressNotification());
    public void onFailed(Job job) {
        if (DEBUG) Log.d(TAG, "onFailed: " + job.id);
        checkArgument(job.failed());
        Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
        mNotificationManager.notify(job.id, 0, job.getFailureNotification());
        onFinished(job);  // failed jobs don't call finished, so we do.
    }

    /**
     * Sets a callback to be run when the next run job is finished.
     * This is test ONLY instrumentation. The alternative is for us to add
     * broadcast intents SOLELY for the purpose of testing.
     * @param listener
     */
    @VisibleForTesting
    void addFinishedListener(TestOnlyListener listener) {
        this.mJobFinishedListener = listener;
    private static final class JobRecord {
        private final Job job;
        private final ScheduledFuture<?> future;

        public JobRecord(Job job, ScheduledFuture<?> future) {
            this.job = job;
            this.future = future;
        }
    }

    /**
     * Only used for testing. Is that obvious enough?
     */
    @VisibleForTesting
    interface TestOnlyListener {
        void onFinished(List<DocumentInfo> failed);
    @Override
    public IBinder onBind(Intent intent) {
        return null;  // Boilerplate. See super#onBind
    }
}
+16 −10

File changed.

Preview size limit exceeded, changes collapsed.

+82 −30

File changed.

Preview size limit exceeded, changes collapsed.

+22 −32

File changed.

Preview size limit exceeded, changes collapsed.

Loading