Home | History | Annotate | Download | only in services
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.documentsui.services;
     18 
     19 import static com.android.documentsui.base.Shared.DEBUG;
     20 
     21 import android.annotation.IntDef;
     22 import android.app.Notification;
     23 import android.app.NotificationChannel;
     24 import android.app.NotificationManager;
     25 import android.app.Service;
     26 import android.content.Intent;
     27 import android.os.Handler;
     28 import android.os.IBinder;
     29 import android.os.PowerManager;
     30 import android.os.UserManager;
     31 import android.support.annotation.VisibleForTesting;
     32 import android.util.Log;
     33 
     34 import com.android.documentsui.R;
     35 import com.android.documentsui.base.Features;
     36 
     37 import java.lang.annotation.Retention;
     38 import java.lang.annotation.RetentionPolicy;
     39 import java.util.ArrayList;
     40 import java.util.HashMap;
     41 import java.util.List;
     42 import java.util.Map;
     43 import java.util.concurrent.ExecutorService;
     44 import java.util.concurrent.Executors;
     45 import java.util.concurrent.Future;
     46 import java.util.concurrent.atomic.AtomicReference;
     47 
     48 import javax.annotation.concurrent.GuardedBy;
     49 
     50 public class FileOperationService extends Service implements Job.Listener {
     51 
     52     public static final String TAG = "FileOperationService";
     53 
     54     // Extra used for OperationDialogFragment, Notifications and picking copy destination.
     55     public static final String EXTRA_OPERATION_TYPE = "com.android.documentsui.OPERATION_TYPE";
     56 
     57     // Extras used for OperationDialogFragment...
     58     public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE";
     59     public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
     60 
     61     public static final String EXTRA_FAILED_URIS = "com.android.documentsui.FAILED_URIS";
     62     public static final String EXTRA_FAILED_DOCS = "com.android.documentsui.FAILED_DOCS";
     63 
     64     // Extras used to start or cancel a file operation...
     65     public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID";
     66     public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
     67     public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
     68 
     69     @IntDef(flag = true, value = {
     70             OPERATION_UNKNOWN,
     71             OPERATION_COPY,
     72             OPERATION_COMPRESS,
     73             OPERATION_EXTRACT,
     74             OPERATION_MOVE,
     75             OPERATION_DELETE
     76     })
     77     @Retention(RetentionPolicy.SOURCE)
     78     public @interface OpType {}
     79     public static final int OPERATION_UNKNOWN = -1;
     80     public static final int OPERATION_COPY = 1;
     81     public static final int OPERATION_EXTRACT = 2;
     82     public static final int OPERATION_COMPRESS = 3;
     83     public static final int OPERATION_MOVE = 4;
     84     public static final int OPERATION_DELETE = 5;
     85 
     86     // TODO: Move it to a shared file when more operations are implemented.
     87     public static final int FAILURE_COPY = 1;
     88 
     89     static final String NOTIFICATION_CHANNEL_ID = "channel_id";
     90 
     91     private static final int POOL_SIZE = 2;  // "pool size", not *max* "pool size".
     92 
     93     private static final int NOTIFICATION_ID_PROGRESS = 0;
     94     private static final int NOTIFICATION_ID_FAILURE = 1;
     95     private static final int NOTIFICATION_ID_WARNING = 2;
     96 
     97     // The executor and job factory are visible for testing and non-final
     98     // so we'll have a way to inject test doubles from the test. It's
     99     // a sub-optimal arrangement.
    100     @VisibleForTesting ExecutorService executor;
    101 
    102     // Use a separate thread pool to prioritize deletions.
    103     @VisibleForTesting ExecutorService deletionExecutor;
    104 
    105     // Use a handler to schedule monitor tasks.
    106     @VisibleForTesting Handler handler;
    107 
    108     // Use a foreground manager to change foreground state of this service.
    109     @VisibleForTesting ForegroundManager foregroundManager;
    110 
    111     // Use a notification manager to post and cancel notifications for jobs.
    112     @VisibleForTesting NotificationManager notificationManager;
    113 
    114     // Use a features to determine if notification channel is enabled.
    115     @VisibleForTesting Features features;
    116 
    117     @GuardedBy("mJobs")
    118     private final Map<String, JobRecord> mJobs = new HashMap<>();
    119 
    120     // The job whose notification is used to keep the service in foreground mode.
    121     private final AtomicReference<Job> mForegroundJob = new AtomicReference<>();
    122 
    123     private PowerManager mPowerManager;
    124     private PowerManager.WakeLock mWakeLock;  // the wake lock, if held.
    125 
    126     private int mLastServiceId;
    127 
    128     @Override
    129     public void onCreate() {
    130         // Allow tests to pre-set these with test doubles.
    131         if (executor == null) {
    132             executor = Executors.newFixedThreadPool(POOL_SIZE);
    133         }
    134 
    135         if (deletionExecutor == null) {
    136             deletionExecutor = Executors.newCachedThreadPool();
    137         }
    138 
    139         if (handler == null) {
    140             // Monitor tasks are small enough to schedule them on main thread.
    141             handler = new Handler();
    142         }
    143 
    144         if (foregroundManager == null) {
    145             foregroundManager = createForegroundManager(this);
    146         }
    147 
    148         if (notificationManager == null) {
    149             notificationManager = getSystemService(NotificationManager.class);
    150         }
    151 
    152         features = new Features.RuntimeFeatures(getResources(), UserManager.get(this));
    153         setUpNotificationChannel();
    154 
    155         if (DEBUG) Log.d(TAG, "Created.");
    156         mPowerManager = getSystemService(PowerManager.class);
    157     }
    158 
    159     private void setUpNotificationChannel() {
    160         if (features.isNotificationChannelEnabled()) {
    161             NotificationChannel channel = new NotificationChannel(
    162                     NOTIFICATION_CHANNEL_ID,
    163                     getString(R.string.app_label),
    164                     NotificationManager.IMPORTANCE_LOW);
    165             notificationManager.createNotificationChannel(channel);
    166         }
    167     }
    168 
    169     @Override
    170     public void onDestroy() {
    171         if (DEBUG) Log.d(TAG, "Shutting down executor.");
    172 
    173         List<Runnable> unfinishedCopies = executor.shutdownNow();
    174         List<Runnable> unfinishedDeletions = deletionExecutor.shutdownNow();
    175         List<Runnable> unfinished =
    176                 new ArrayList<>(unfinishedCopies.size() + unfinishedDeletions.size());
    177         unfinished.addAll(unfinishedCopies);
    178         unfinished.addAll(unfinishedDeletions);
    179         if (!unfinished.isEmpty()) {
    180             Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished);
    181         }
    182 
    183         executor = null;
    184         deletionExecutor = null;
    185         handler = null;
    186 
    187         if (DEBUG) Log.d(TAG, "Destroyed.");
    188     }
    189 
    190     @Override
    191     public int onStartCommand(Intent intent, int flags, int serviceId) {
    192         // TODO: Ensure we're not being called with retry or redeliver.
    193         // checkArgument(flags == 0);  // retry and redeliver are not supported.
    194 
    195         String jobId = intent.getStringExtra(EXTRA_JOB_ID);
    196         assert(jobId != null);
    197 
    198         if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId);
    199 
    200         if (intent.hasExtra(EXTRA_CANCEL)) {
    201             handleCancel(intent);
    202         } else {
    203             FileOperation operation = intent.getParcelableExtra(EXTRA_OPERATION);
    204             handleOperation(jobId, operation);
    205         }
    206 
    207         // Track the service supplied id so we can stop the service once we're out of work to do.
    208         mLastServiceId = serviceId;
    209 
    210         return START_NOT_STICKY;
    211     }
    212 
    213     private void handleOperation(String jobId, FileOperation operation) {
    214         synchronized (mJobs) {
    215             if (mWakeLock == null) {
    216                 mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
    217             }
    218 
    219             if (mJobs.containsKey(jobId)) {
    220                 Log.w(TAG, "Duplicate job id: " + jobId
    221                         + ". Ignoring job request for operation: " + operation + ".");
    222                 return;
    223             }
    224 
    225             Job job = operation.createJob(this, this, jobId, features);
    226 
    227             if (job == null) {
    228                 return;
    229             }
    230 
    231             assert (job != null);
    232             if (DEBUG) Log.d(TAG, "Scheduling job " + job.id + ".");
    233             Future<?> future = getExecutorService(operation.getOpType()).submit(job);
    234             mJobs.put(jobId, new JobRecord(job, future));
    235 
    236             // Acquire wake lock to keep CPU running until we finish all jobs. Acquire wake lock
    237             // after we create a job and put it in mJobs to avoid potential leaking of wake lock
    238             // in case where job creation fails.
    239             mWakeLock.acquire();
    240         }
    241     }
    242 
    243     /**
    244      * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID".
    245      *
    246      * @param intent The cancellation intent.
    247      */
    248     private void handleCancel(Intent intent) {
    249         assert(intent.hasExtra(EXTRA_CANCEL));
    250         assert(intent.getStringExtra(EXTRA_JOB_ID) != null);
    251 
    252         String jobId = intent.getStringExtra(EXTRA_JOB_ID);
    253 
    254         if (DEBUG) Log.d(TAG, "handleCancel: " + jobId);
    255 
    256         synchronized (mJobs) {
    257             // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
    258             // cancellation requests from affecting unrelated copy jobs.  However, if the current job ID
    259             // is null, the service most likely crashed and was revived by the incoming cancel intent.
    260             // In that case, always allow the cancellation to proceed.
    261             JobRecord record = mJobs.get(jobId);
    262             if (record != null) {
    263                 record.job.cancel();
    264             }
    265         }
    266 
    267         // Dismiss the progress notification here rather than in the copy loop. This preserves
    268         // interactivity for the user in case the copy loop is stalled.
    269         // Try to cancel it even if we don't have a job id...in case there is some sad
    270         // orphan notification.
    271         notificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS);
    272 
    273         // TODO: Guarantee the job is being finalized
    274     }
    275 
    276     private ExecutorService getExecutorService(@OpType int operationType) {
    277         switch (operationType) {
    278             case OPERATION_COPY:
    279             case OPERATION_COMPRESS:
    280             case OPERATION_EXTRACT:
    281             case OPERATION_MOVE:
    282                 return executor;
    283             case OPERATION_DELETE:
    284                 return deletionExecutor;
    285             default:
    286                 throw new UnsupportedOperationException();
    287         }
    288     }
    289 
    290     @GuardedBy("mJobs")
    291     private void deleteJob(Job job) {
    292         if (DEBUG) Log.d(TAG, "deleteJob: " + job.id);
    293 
    294         // Release wake lock before clearing jobs just in case we fail to clean them up.
    295         mWakeLock.release();
    296         if (!mWakeLock.isHeld()) {
    297             mWakeLock = null;
    298         }
    299 
    300         JobRecord record = mJobs.remove(job.id);
    301         assert(record != null);
    302         record.job.cleanup();
    303 
    304         // Delay the shutdown until we've cleaned up all notifications. shutdown() is now posted in
    305         // onFinished(Job job) to main thread.
    306     }
    307 
    308     /**
    309      * Most likely shuts down. Won't shut down if service has a pending
    310      * message. Thread pool is deal with in onDestroy.
    311      */
    312     private void shutdown() {
    313         if (DEBUG) Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId);
    314         assert(mWakeLock == null);
    315 
    316         // Turns out, for us, stopSelfResult always returns false in tests,
    317         // so we can't guard executor shutdown. For this reason we move
    318         // executor shutdown to #onDestroy.
    319         boolean gonnaStop = stopSelfResult(mLastServiceId);
    320         if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop);
    321         if (!gonnaStop) {
    322             Log.w(TAG, "Service should be stopping, but reports otherwise.");
    323         }
    324     }
    325 
    326     @VisibleForTesting
    327     boolean holdsWakeLock() {
    328         return mWakeLock != null && mWakeLock.isHeld();
    329     }
    330 
    331     @Override
    332     public void onStart(Job job) {
    333         if (DEBUG) Log.d(TAG, "onStart: " + job.id);
    334 
    335         Notification notification = job.getSetupNotification();
    336         // If there is no foreground job yet, set this job to foreground job.
    337         if (mForegroundJob.compareAndSet(null, job)) {
    338             if (DEBUG) Log.d(TAG, "Set foreground job to " + job.id);
    339             foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification);
    340         }
    341 
    342         // Show start up notification
    343         if (DEBUG) Log.d(TAG, "Posting notification for " + job.id);
    344         notificationManager.notify(
    345                 job.id, NOTIFICATION_ID_PROGRESS, notification);
    346 
    347         // Set up related monitor
    348         JobMonitor monitor = new JobMonitor(job, notificationManager, handler);
    349         monitor.start();
    350     }
    351 
    352     @Override
    353     public void onFinished(Job job) {
    354         assert(job.isFinished());
    355         if (DEBUG) Log.d(TAG, "onFinished: " + job.id);
    356 
    357         synchronized (mJobs) {
    358             // Delete the job from mJobs first to avoid this job being selected as the foreground
    359             // task again if we need to swap the foreground job.
    360             deleteJob(job);
    361 
    362             // Update foreground state before cleaning up notification. If the finishing job is the
    363             // foreground job, we would need to switch to another one or go to background before
    364             // we can clean up notifications.
    365             updateForegroundState(job);
    366 
    367             // Use the same thread of monitors to tackle notifications to avoid race conditions.
    368             // Otherwise we may fail to dismiss progress notification.
    369             handler.post(() -> cleanUpNotification(job));
    370 
    371             // Post the shutdown message to main thread after cleanUpNotification() to give it a
    372             // chance to run. Otherwise this process may be torn down by Android before we've
    373             // cleaned up the notifications of the last job.
    374             if (mJobs.isEmpty()) {
    375                 handler.post(this::shutdown);
    376             }
    377         }
    378     }
    379 
    380     @GuardedBy("mJobs")
    381     private void updateForegroundState(Job job) {
    382         Job candidate = mJobs.isEmpty() ? null : mJobs.values().iterator().next().job;
    383 
    384         // If foreground job is retiring and there is still work to do, we need to set it to a new
    385         // job.
    386         if (mForegroundJob.compareAndSet(job, candidate)) {
    387             if (candidate == null) {
    388                 if (DEBUG) Log.d(TAG, "Stop foreground");
    389                 // Remove the notification here just in case we're torn down before we have the
    390                 // chance to clean up notifications.
    391                 foregroundManager.stopForeground(true);
    392             } else {
    393                 if (DEBUG) Log.d(TAG, "Switch foreground job to " + candidate.id);
    394 
    395                 Notification notification = (candidate.getState() == Job.STATE_STARTED)
    396                         ? candidate.getSetupNotification()
    397                         : candidate.getProgressNotification();
    398                 foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification);
    399                 notificationManager.notify(candidate.id, NOTIFICATION_ID_PROGRESS,
    400                         notification);
    401             }
    402         }
    403     }
    404 
    405     private void cleanUpNotification(Job job) {
    406 
    407         if (DEBUG) Log.d(TAG, "Canceling notification for " + job.id);
    408         // Dismiss the ongoing copy notification when the copy is done.
    409         notificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);
    410 
    411         if (job.hasFailures()) {
    412             if (!job.failedUris.isEmpty()) {
    413                 Log.e(TAG, "Job failed to resolve uris: " + job.failedUris + ".");
    414             }
    415             if (!job.failedDocs.isEmpty()) {
    416                 Log.e(TAG, "Job failed to process docs: " + job.failedDocs + ".");
    417             }
    418             notificationManager.notify(
    419                     job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
    420         }
    421 
    422         if (job.hasWarnings()) {
    423             if (DEBUG) Log.d(TAG, "Job finished with warnings.");
    424             notificationManager.notify(
    425                     job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
    426         }
    427     }
    428 
    429     private static final class JobRecord {
    430         private final Job job;
    431         private final Future<?> future;
    432 
    433         public JobRecord(Job job, Future<?> future) {
    434             this.job = job;
    435             this.future = future;
    436         }
    437     }
    438 
    439     /**
    440      * A class used to periodically polls state of a job.
    441      *
    442      * <p>It's possible that jobs hang because underlying document providers stop responding. We
    443      * still need to update notifications if jobs hang, so instead of jobs pushing their states,
    444      * we poll states of jobs.
    445      */
    446     private static final class JobMonitor implements Runnable {
    447         private static final long PROGRESS_INTERVAL_MILLIS = 500L;
    448 
    449         private final Job mJob;
    450         private final NotificationManager mNotificationManager;
    451         private final Handler mHandler;
    452 
    453         private JobMonitor(Job job, NotificationManager notificationManager, Handler handler) {
    454             mJob = job;
    455             mNotificationManager = notificationManager;
    456             mHandler = handler;
    457         }
    458 
    459         private void start() {
    460             mHandler.post(this);
    461         }
    462 
    463         @Override
    464         public void run() {
    465             if (mJob.isFinished()) {
    466                 // Finish notification is already shown. Progress notification is removed.
    467                 // Just finish itself.
    468                 return;
    469             }
    470 
    471             // Only job in set up state has progress bar
    472             if (mJob.getState() == Job.STATE_SET_UP) {
    473                 mNotificationManager.notify(
    474                         mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification());
    475             }
    476 
    477             mHandler.postDelayed(this, PROGRESS_INTERVAL_MILLIS);
    478         }
    479     }
    480 
    481     @Override
    482     public IBinder onBind(Intent intent) {
    483         return null;  // Boilerplate. See super#onBind
    484     }
    485 
    486     private static ForegroundManager createForegroundManager(final Service service) {
    487         return new ForegroundManager() {
    488             @Override
    489             public void startForeground(int id, Notification notification) {
    490                 service.startForeground(id, notification);
    491             }
    492 
    493             @Override
    494             public void stopForeground(boolean removeNotification) {
    495                 service.stopForeground(removeNotification);
    496             }
    497         };
    498     }
    499 
    500     @VisibleForTesting
    501     interface ForegroundManager {
    502         void startForeground(int id, Notification notification);
    503         void stopForeground(boolean removeNotification);
    504     }
    505 }
    506