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.Shared.DEBUG;
     20 
     21 import android.annotation.IntDef;
     22 import android.app.NotificationManager;
     23 import android.app.Service;
     24 import android.content.Intent;
     25 import android.os.IBinder;
     26 import android.os.PowerManager;
     27 import android.support.annotation.Nullable;
     28 import android.support.annotation.VisibleForTesting;
     29 import android.util.Log;
     30 
     31 import com.android.documentsui.Shared;
     32 import com.android.documentsui.model.DocumentInfo;
     33 import com.android.documentsui.model.DocumentStack;
     34 import com.android.documentsui.services.Job.Factory;
     35 
     36 import java.lang.annotation.Retention;
     37 import java.lang.annotation.RetentionPolicy;
     38 import java.util.HashMap;
     39 import java.util.List;
     40 import java.util.Map;
     41 import java.util.concurrent.ScheduledExecutorService;
     42 import java.util.concurrent.ScheduledFuture;
     43 import java.util.concurrent.ScheduledThreadPoolExecutor;
     44 import java.util.concurrent.TimeUnit;
     45 
     46 import javax.annotation.concurrent.GuardedBy;
     47 
     48 public class FileOperationService extends Service implements Job.Listener {
     49 
     50     private static final int DEFAULT_DELAY = 0;
     51     private static final int MAX_DELAY = 10 * 1000;  // ten seconds
     52     private static final int POOL_SIZE = 2;  // "pool size", not *max* "pool size".
     53     private static final int NOTIFICATION_ID_PROGRESS = 0;
     54     private static final int NOTIFICATION_ID_FAILURE = 1;
     55     private static final int NOTIFICATION_ID_WARNING = 2;
     56 
     57     public static final String TAG = "FileOperationService";
     58 
     59     public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID";
     60     public static final String EXTRA_DELAY = "com.android.documentsui.DELAY";
     61     public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
     62     public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
     63     public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
     64     public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE";
     65 
     66     // This extra is used only for moving and deleting. Currently it's not the case,
     67     // but in the future those files may be from multiple different parents. In
     68     // such case, this needs to be replaced with pairs of parent and child.
     69     public static final String EXTRA_SRC_PARENT = "com.android.documentsui.SRC_PARENT";
     70 
     71     @IntDef(flag = true, value = {
     72             OPERATION_UNKNOWN,
     73             OPERATION_COPY,
     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_MOVE = 2;
     82     public static final int OPERATION_DELETE = 3;
     83 
     84     // TODO: Move it to a shared file when more operations are implemented.
     85     public static final int FAILURE_COPY = 1;
     86 
     87     // The executor and job factory are visible for testing and non-final
     88     // so we'll have a way to inject test doubles from the test. It's
     89     // a sub-optimal arrangement.
     90     @VisibleForTesting ScheduledExecutorService executor;
     91     @VisibleForTesting Factory jobFactory;
     92 
     93     private PowerManager mPowerManager;
     94     private PowerManager.WakeLock mWakeLock;  // the wake lock, if held.
     95     private NotificationManager mNotificationManager;
     96 
     97     @GuardedBy("mRunning")
     98     private Map<String, JobRecord> mRunning = new HashMap<>();
     99 
    100     private int mLastServiceId;
    101 
    102     @Override
    103     public void onCreate() {
    104         // Allow tests to pre-set these with test doubles.
    105         if (executor == null) {
    106             executor = new ScheduledThreadPoolExecutor(POOL_SIZE);
    107         }
    108 
    109         if (jobFactory == null) {
    110             jobFactory = Job.Factory.instance;
    111         }
    112 
    113         if (DEBUG) Log.d(TAG, "Created.");
    114         mPowerManager = getSystemService(PowerManager.class);
    115         mNotificationManager = getSystemService(NotificationManager.class);
    116     }
    117 
    118     @Override
    119     public void onDestroy() {
    120         if (DEBUG) Log.d(TAG, "Shutting down executor.");
    121         List<Runnable> unfinished = executor.shutdownNow();
    122         if (!unfinished.isEmpty()) {
    123             Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished);
    124         }
    125         executor = null;
    126         if (DEBUG) Log.d(TAG, "Destroyed.");
    127     }
    128 
    129     @Override
    130     public int onStartCommand(Intent intent, int flags, int serviceId) {
    131         // TODO: Ensure we're not being called with retry or redeliver.
    132         // checkArgument(flags == 0);  // retry and redeliver are not supported.
    133 
    134         String jobId = intent.getStringExtra(EXTRA_JOB_ID);
    135         @OpType int operationType = intent.getIntExtra(EXTRA_OPERATION, OPERATION_UNKNOWN);
    136         assert(jobId != null);
    137 
    138         if (intent.hasExtra(EXTRA_CANCEL)) {
    139             handleCancel(intent);
    140         } else {
    141             assert(operationType != OPERATION_UNKNOWN);
    142             handleOperation(intent, serviceId, jobId, operationType);
    143         }
    144 
    145         return START_NOT_STICKY;
    146     }
    147 
    148     private void handleOperation(Intent intent, int serviceId, String jobId, int operationType) {
    149         if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId);
    150 
    151         // Track the service supplied id so we can stop the service once we're out of work to do.
    152         mLastServiceId = serviceId;
    153 
    154         Job job = null;
    155         synchronized (mRunning) {
    156             if (mWakeLock == null) {
    157                 mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
    158             }
    159 
    160             List<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
    161             DocumentInfo srcParent = intent.getParcelableExtra(EXTRA_SRC_PARENT);
    162             DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK);
    163 
    164             job = createJob(operationType, jobId, srcs, srcParent, stack);
    165 
    166             if (job == null) {
    167                 return;
    168             }
    169 
    170             mWakeLock.acquire();
    171         }
    172 
    173         assert(job != null);
    174         int delay = intent.getIntExtra(EXTRA_DELAY, DEFAULT_DELAY);
    175         assert(delay <= MAX_DELAY);
    176         if (DEBUG) Log.d(
    177                 TAG, "Scheduling job " + job.id + " to run in " + delay + " milliseconds.");
    178         ScheduledFuture<?> future = executor.schedule(job, delay, TimeUnit.MILLISECONDS);
    179         mRunning.put(jobId, new JobRecord(job, future));
    180     }
    181 
    182     /**
    183      * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID".
    184      *
    185      * @param intent The cancellation intent.
    186      */
    187     private void handleCancel(Intent intent) {
    188         assert(intent.hasExtra(EXTRA_CANCEL));
    189         assert(intent.getStringExtra(EXTRA_JOB_ID) != null);
    190 
    191         String jobId = intent.getStringExtra(EXTRA_JOB_ID);
    192 
    193         if (DEBUG) Log.d(TAG, "handleCancel: " + jobId);
    194 
    195         synchronized (mRunning) {
    196             // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
    197             // cancellation requests from affecting unrelated copy jobs.  However, if the current job ID
    198             // is null, the service most likely crashed and was revived by the incoming cancel intent.
    199             // In that case, always allow the cancellation to proceed.
    200             JobRecord record = mRunning.get(jobId);
    201             if (record != null) {
    202                 record.job.cancel();
    203 
    204                 // If the job hasn't been started, cancel it and explicitly clean up.
    205                 // If it *has* been started, we wait for it to recognize this, then
    206                 // allow it stop working in an orderly fashion.
    207                 if (record.future.getDelay(TimeUnit.MILLISECONDS) > 0) {
    208                     record.future.cancel(false);
    209                     onFinished(record.job);
    210                 }
    211             }
    212         }
    213 
    214         // Dismiss the progress notification here rather than in the copy loop. This preserves
    215         // interactivity for the user in case the copy loop is stalled.
    216         // Try to cancel it even if we don't have a job id...in case there is some sad
    217         // orphan notification.
    218         mNotificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS);
    219 
    220         // TODO: Guarantee the job is being finalized
    221     }
    222 
    223     /**
    224      * Creates a new job. Returns null if a job with {@code id} already exists.
    225      * @return
    226      */
    227     @GuardedBy("mRunning")
    228     private @Nullable Job createJob(
    229             @OpType int operationType, String id, List<DocumentInfo> srcs, DocumentInfo srcParent,
    230             DocumentStack stack) {
    231 
    232         if (srcs.isEmpty()) {
    233             Log.w(TAG, "Ignoring job request with empty srcs list. Id: " + id);
    234             return null;
    235         }
    236 
    237         if (mRunning.containsKey(id)) {
    238             Log.w(TAG, "Duplicate job id: " + id
    239                     + ". Ignoring job request for srcs: " + srcs + ", stack: " + stack + ".");
    240             return null;
    241         }
    242 
    243         switch (operationType) {
    244             case OPERATION_COPY:
    245                 return jobFactory.createCopy(
    246                         this, getApplicationContext(), this, id, stack, srcs);
    247             case OPERATION_MOVE:
    248                 return jobFactory.createMove(
    249                         this, getApplicationContext(), this, id, stack, srcs,
    250                         srcParent);
    251             case OPERATION_DELETE:
    252                 return jobFactory.createDelete(
    253                         this, getApplicationContext(), this, id, stack, srcs,
    254                         srcParent);
    255             default:
    256                 throw new UnsupportedOperationException();
    257         }
    258     }
    259 
    260     @GuardedBy("mRunning")
    261     private void deleteJob(Job job) {
    262         if (DEBUG) Log.d(TAG, "deleteJob: " + job.id);
    263 
    264         JobRecord record = mRunning.remove(job.id);
    265         assert(record != null);
    266         record.job.cleanup();
    267 
    268         if (mRunning.isEmpty()) {
    269             shutdown();
    270         }
    271     }
    272 
    273     /**
    274      * Most likely shuts down. Won't shut down if service has a pending
    275      * message. Thread pool is deal with in onDestroy.
    276      */
    277     private void shutdown() {
    278         if (DEBUG) Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId);
    279         mWakeLock.release();
    280         mWakeLock = null;
    281 
    282         // Turns out, for us, stopSelfResult always returns false in tests,
    283         // so we can't guard executor shutdown. For this reason we move
    284         // executor shutdown to #onDestroy.
    285         boolean gonnaStop = stopSelfResult(mLastServiceId);
    286         if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop);
    287         if (!gonnaStop) {
    288             Log.w(TAG, "Service should be stopping, but reports otherwise.");
    289         }
    290     }
    291 
    292     @VisibleForTesting
    293     boolean holdsWakeLock() {
    294         return mWakeLock != null && mWakeLock.isHeld();
    295     }
    296 
    297     @Override
    298     public void onStart(Job job) {
    299         if (DEBUG) Log.d(TAG, "onStart: " + job.id);
    300         mNotificationManager.notify(job.id, NOTIFICATION_ID_PROGRESS, job.getSetupNotification());
    301     }
    302 
    303     @Override
    304     public void onFinished(Job job) {
    305         if (DEBUG) Log.d(TAG, "onFinished: " + job.id);
    306 
    307         // Dismiss the ongoing copy notification when the copy is done.
    308         mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);
    309 
    310         if (job.hasFailures()) {
    311             Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
    312             mNotificationManager.notify(
    313                 job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
    314         }
    315 
    316         if (job.hasWarnings()) {
    317             if (DEBUG) Log.d(TAG, "Job finished with warnings.");
    318             mNotificationManager.notify(
    319                     job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
    320         }
    321 
    322         synchronized (mRunning) {
    323             deleteJob(job);
    324         }
    325     }
    326 
    327     @Override
    328     public void onProgress(CopyJob job) {
    329         if (DEBUG) Log.d(TAG, "onProgress: " + job.id);
    330         mNotificationManager.notify(
    331                 job.id, NOTIFICATION_ID_PROGRESS, job.getProgressNotification());
    332     }
    333 
    334     private static final class JobRecord {
    335         private final Job job;
    336         private final ScheduledFuture<?> future;
    337 
    338         public JobRecord(Job job, ScheduledFuture<?> future) {
    339             this.job = job;
    340             this.future = future;
    341         }
    342     }
    343 
    344     @Override
    345     public IBinder onBind(Intent intent) {
    346         return null;  // Boilerplate. See super#onBind
    347     }
    348 }
    349