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
onCreate()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
onDestroy()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
onStartCommand(Intent intent, int flags, int serviceId)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 
handleOperation(Intent intent, int serviceId, String jobId, int operationType)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      */
handleCancel(Intent intent)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")
createJob( @pType int operationType, String id, List<DocumentInfo> srcs, DocumentInfo srcParent, DocumentStack stack)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")
deleteJob(Job job)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      */
shutdown()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
holdsWakeLock()293     boolean holdsWakeLock() {
294         return mWakeLock != null && mWakeLock.isHeld();
295     }
296 
297     @Override
onStart(Job job)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
onFinished(Job job)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
onProgress(CopyJob job)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 
JobRecord(Job job, ScheduledFuture<?> future)338         public JobRecord(Job job, ScheduledFuture<?> future) {
339             this.job = job;
340             this.future = future;
341         }
342     }
343 
344     @Override
onBind(Intent intent)345     public IBinder onBind(Intent intent) {
346         return null;  // Boilerplate. See super#onBind
347     }
348 }
349