/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.services; import static com.android.documentsui.base.SharedMinimal.DEBUG; import androidx.annotation.IntDef; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Handler; import android.os.IBinder; import android.os.PowerManager; import android.os.UserManager; import androidx.annotation.VisibleForTesting; import android.util.Log; import com.android.documentsui.R; import com.android.documentsui.base.Features; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import javax.annotation.concurrent.GuardedBy; public class FileOperationService extends Service implements Job.Listener { public static final String TAG = "FileOperationService"; // Extra used for OperationDialogFragment, Notifications and picking copy destination. public static final String EXTRA_OPERATION_TYPE = "com.android.documentsui.OPERATION_TYPE"; // Extras used for OperationDialogFragment... public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE"; public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; public static final String EXTRA_FAILED_URIS = "com.android.documentsui.FAILED_URIS"; public static final String EXTRA_FAILED_DOCS = "com.android.documentsui.FAILED_DOCS"; // Extras used to start or cancel a file operation... public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID"; public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION"; public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; @IntDef({ OPERATION_UNKNOWN, OPERATION_COPY, OPERATION_COMPRESS, OPERATION_EXTRACT, OPERATION_MOVE, OPERATION_DELETE }) @Retention(RetentionPolicy.SOURCE) public @interface OpType {} public static final int OPERATION_UNKNOWN = -1; public static final int OPERATION_COPY = 1; public static final int OPERATION_EXTRACT = 2; public static final int OPERATION_COMPRESS = 3; public static final int OPERATION_MOVE = 4; public static final int OPERATION_DELETE = 5; @IntDef({ MESSAGE_PROGRESS, MESSAGE_FINISH }) @Retention(RetentionPolicy.SOURCE) public @interface MessageType {} public static final int MESSAGE_PROGRESS = 0; public static final int MESSAGE_FINISH = 1; // TODO: Move it to a shared file when more operations are implemented. public static final int FAILURE_COPY = 1; static final String NOTIFICATION_CHANNEL_ID = "channel_id"; private static final int POOL_SIZE = 2; // "pool size", not *max* "pool size". @VisibleForTesting static final int NOTIFICATION_ID_PROGRESS = 1; private static final int NOTIFICATION_ID_FAILURE = 2; private static final int NOTIFICATION_ID_WARNING = 3; // 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 ExecutorService executor; // Use a separate thread pool to prioritize deletions. @VisibleForTesting ExecutorService deletionExecutor; // Use a handler to schedule monitor tasks. @VisibleForTesting Handler handler; // Use a foreground manager to change foreground state of this service. @VisibleForTesting ForegroundManager foregroundManager; // Use a notification manager to post and cancel notifications for jobs. @VisibleForTesting NotificationManager notificationManager; // Use a features to determine if notification channel is enabled. @VisibleForTesting Features features; @GuardedBy("mJobs") private final Map mJobs = new LinkedHashMap<>(); // The job whose notification is used to keep the service in foreground mode. @GuardedBy("mJobs") private Job mForegroundJob; private PowerManager mPowerManager; private PowerManager.WakeLock mWakeLock; // the wake lock, if held. private int mLastServiceId; @Override public void onCreate() { // Allow tests to pre-set these with test doubles. if (executor == null) { executor = Executors.newFixedThreadPool(POOL_SIZE); } if (deletionExecutor == null) { deletionExecutor = Executors.newCachedThreadPool(); } if (handler == null) { // Monitor tasks are small enough to schedule them on main thread. handler = new Handler(); } if (foregroundManager == null) { foregroundManager = createForegroundManager(this); } if (notificationManager == null) { notificationManager = getSystemService(NotificationManager.class); } UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); features = new Features.RuntimeFeatures(getResources(), userManager); setUpNotificationChannel(); if (DEBUG) { Log.d(TAG, "Created."); } mPowerManager = getSystemService(PowerManager.class); } private void setUpNotificationChannel() { if (features.isNotificationChannelEnabled()) { NotificationChannel channel = new NotificationChannel( NOTIFICATION_CHANNEL_ID, getString(R.string.app_label), NotificationManager.IMPORTANCE_LOW); notificationManager.createNotificationChannel(channel); } } @Override public void onDestroy() { if (DEBUG) { Log.d(TAG, "Shutting down executor."); } List unfinishedCopies = executor.shutdownNow(); List unfinishedDeletions = deletionExecutor.shutdownNow(); List unfinished = new ArrayList<>(unfinishedCopies.size() + unfinishedDeletions.size()); unfinished.addAll(unfinishedCopies); unfinished.addAll(unfinishedDeletions); if (!unfinished.isEmpty()) { Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished); } executor = null; deletionExecutor = null; handler = null; if (DEBUG) { Log.d(TAG, "Destroyed."); } } @Override public int onStartCommand(Intent intent, int flags, int serviceId) { // 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); assert(jobId != null); if (DEBUG) { Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId); } if (intent.hasExtra(EXTRA_CANCEL)) { handleCancel(intent); } else { FileOperation operation = intent.getParcelableExtra(EXTRA_OPERATION); handleOperation(jobId, operation); } // Track the service supplied id so we can stop the service once we're out of work to do. mLastServiceId = serviceId; return START_NOT_STICKY; } private void handleOperation(String jobId, FileOperation operation) { synchronized (mJobs) { if (mWakeLock == null) { mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); } if (mJobs.containsKey(jobId)) { Log.w(TAG, "Duplicate job id: " + jobId + ". Ignoring job request for operation: " + operation + "."); return; } Job job = operation.createJob(this, this, jobId, features); if (job == null) { return; } assert (job != null); if (DEBUG) { Log.d(TAG, "Scheduling job " + job.id + "."); } Future future = getExecutorService(operation.getOpType()).submit(job); mJobs.put(jobId, new JobRecord(job, future)); // Acquire wake lock to keep CPU running until we finish all jobs. Acquire wake lock // after we create a job and put it in mJobs to avoid potential leaking of wake lock // in case where job creation fails. mWakeLock.acquire(); } } /** * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID". * * @param intent The cancellation intent. */ private void handleCancel(Intent intent) { assert(intent.hasExtra(EXTRA_CANCEL)); assert(intent.getStringExtra(EXTRA_JOB_ID) != null); String jobId = intent.getStringExtra(EXTRA_JOB_ID); if (DEBUG) { Log.d(TAG, "handleCancel: " + jobId); } synchronized (mJobs) { // 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. JobRecord record = mJobs.get(jobId); if (record != null) { record.job.cancel(); updateForegroundState(record.job); } } // Dismiss the progress notification here rather than in the copy loop. This preserves // interactivity for the user in case the copy loop is stalled. // Try to cancel it even if we don't have a job id...in case there is some sad // orphan notification. notificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS); // TODO: Guarantee the job is being finalized } private ExecutorService getExecutorService(@OpType int operationType) { switch (operationType) { case OPERATION_COPY: case OPERATION_COMPRESS: case OPERATION_EXTRACT: case OPERATION_MOVE: return executor; case OPERATION_DELETE: return deletionExecutor; default: throw new UnsupportedOperationException(); } } @GuardedBy("mJobs") private void deleteJob(Job job) { if (DEBUG) { Log.d(TAG, "deleteJob: " + job.id); } // Release wake lock before clearing jobs just in case we fail to clean them up. mWakeLock.release(); if (!mWakeLock.isHeld()) { mWakeLock = null; } JobRecord record = mJobs.remove(job.id); assert(record != null); record.job.cleanup(); // Delay the shutdown until we've cleaned up all notifications. shutdown() is now posted in // onFinished(Job job) to main thread. } /** * Most likely shuts down. Won't shut down if service has a pending * message. Thread pool is deal with in onDestroy. */ private void shutdown() { if (DEBUG) { Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId); } assert(mWakeLock == null); // Turns out, for us, stopSelfResult always returns false in tests, // so we can't guard executor shutdown. For this reason we move // executor shutdown to #onDestroy. boolean gonnaStop = stopSelfResult(mLastServiceId); if (DEBUG) { Log.d(TAG, "Stopping service: " + gonnaStop); } if (!gonnaStop) { Log.w(TAG, "Service should be stopping, but reports otherwise."); } } @VisibleForTesting boolean holdsWakeLock() { return mWakeLock != null && mWakeLock.isHeld(); } @Override public void onStart(Job job) { if (DEBUG) { Log.d(TAG, "onStart: " + job.id); } Notification notification = job.getSetupNotification(); // If there is no foreground job yet, set this job to foreground job. synchronized (mJobs) { if (mForegroundJob == null) { if (DEBUG) { Log.d(TAG, "Set foreground job to " + job.id); } mForegroundJob = job; foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification); } else { // Show start up notification if (DEBUG) { Log.d(TAG, "Posting notification for " + job.id); } notificationManager.notify( mForegroundJob == job ? null : job.id, NOTIFICATION_ID_PROGRESS, notification); } } // Set up related monitor JobMonitor monitor = new JobMonitor(job); monitor.start(); } @Override public void onFinished(Job job) { assert(job.isFinished()); if (DEBUG) { Log.d(TAG, "onFinished: " + job.id); } synchronized (mJobs) { // Delete the job from mJobs first to avoid this job being selected as the foreground // task again if we need to swap the foreground job. deleteJob(job); // Update foreground state before cleaning up notification. If the finishing job is the // foreground job, we would need to switch to another one or go to background before // we can clean up notifications. updateForegroundState(job); // Use the same thread of monitors to tackle notifications to avoid race conditions. // Otherwise we may fail to dismiss progress notification. handler.post(() -> cleanUpNotification(job)); // Post the shutdown message to main thread after cleanUpNotification() to give it a // chance to run. Otherwise this process may be torn down by Android before we've // cleaned up the notifications of the last job. if (mJobs.isEmpty()) { handler.post(this::shutdown); } } } @GuardedBy("mJobs") private void updateForegroundState(Job job) { Job candidate = getCandidateForegroundJob(); // If foreground job is retiring and there is still work to do, we need to set it to a new // job. if (mForegroundJob == job) { mForegroundJob = candidate; if (candidate == null) { if (DEBUG) { Log.d(TAG, "Stop foreground"); } // Remove the notification here just in case we're torn down before we have the // chance to clean up notifications. foregroundManager.stopForeground(true); } else { if (DEBUG) { Log.d(TAG, "Switch foreground job to " + candidate.id); } notificationManager.cancel(candidate.id, NOTIFICATION_ID_PROGRESS); Notification notification = (candidate.getState() == Job.STATE_STARTED) ? candidate.getSetupNotification() : candidate.getProgressNotification(); notificationManager.notify(NOTIFICATION_ID_PROGRESS, notification); } } } private void cleanUpNotification(Job job) { if (DEBUG) { Log.d(TAG, "Canceling notification for " + job.id); } // Dismiss the ongoing copy notification when the copy is done. notificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS); if (job.hasFailures()) { if (!job.failedUris.isEmpty()) { Log.e(TAG, "Job failed to resolve uris: " + job.failedUris + "."); } if (!job.failedDocs.isEmpty()) { Log.e(TAG, "Job failed to process docs: " + job.failedDocs + "."); } notificationManager.notify( job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification()); } if (job.hasWarnings()) { if (DEBUG) { Log.d(TAG, "Job finished with warnings."); } notificationManager.notify( job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification()); } } @GuardedBy("mJobs") private Job getCandidateForegroundJob() { if (mJobs.isEmpty()) { return null; } for (JobRecord rec : mJobs.values()) { if (!rec.job.isFinished()) { return rec.job; } } return null; } private static final class JobRecord { private final Job job; private final Future future; public JobRecord(Job job, Future future) { this.job = job; this.future = future; } } /** * A class used to periodically polls state of a job. * *

It's possible that jobs hang because underlying document providers stop responding. We * still need to update notifications if jobs hang, so instead of jobs pushing their states, * we poll states of jobs. */ private final class JobMonitor implements Runnable { private static final long PROGRESS_INTERVAL_MILLIS = 500L; private final Job mJob; private JobMonitor(Job job) { mJob = job; } private void start() { handler.post(this); } @Override public void run() { synchronized (mJobs) { if (mJob.isFinished()) { // Finish notification is already shown. Progress notification is removed. // Just finish itself. return; } // Only job in set up state has progress bar if (mJob.getState() == Job.STATE_SET_UP) { notificationManager.notify( mForegroundJob == mJob ? null : mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification()); } handler.postDelayed(this, PROGRESS_INTERVAL_MILLIS); } } } @Override public IBinder onBind(Intent intent) { return null; // Boilerplate. See super#onBind } private static ForegroundManager createForegroundManager(final Service service) { return new ForegroundManager() { @Override public void startForeground(int id, Notification notification) { service.startForeground(id, notification); } @Override public void stopForeground(boolean removeNotification) { service.stopForeground(removeNotification); } }; } @VisibleForTesting interface ForegroundManager { void startForeground(int id, Notification notification); void stopForeground(boolean removeNotification); } }