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
onCreate()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 
setUpNotificationChannel()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
onDestroy()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
onStartCommand(Intent intent, int flags, int serviceId)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 
handleOperation(String jobId, FileOperation operation)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      */
handleCancel(Intent intent)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 
getExecutorService(@pType int operationType)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")
deleteJob(Job job)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      */
shutdown()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
holdsWakeLock()327     boolean holdsWakeLock() {
328         return mWakeLock != null && mWakeLock.isHeld();
329     }
330 
331     @Override
onStart(Job job)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
onFinished(Job job)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")
updateForegroundState(Job job)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 
cleanUpNotification(Job job)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 
JobRecord(Job job, Future<?> future)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 
JobMonitor(Job job, NotificationManager notificationManager, Handler handler)453         private JobMonitor(Job job, NotificationManager notificationManager, Handler handler) {
454             mJob = job;
455             mNotificationManager = notificationManager;
456             mHandler = handler;
457         }
458 
start()459         private void start() {
460             mHandler.post(this);
461         }
462 
463         @Override
run()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
onBind(Intent intent)482     public IBinder onBind(Intent intent) {
483         return null;  // Boilerplate. See super#onBind
484     }
485 
createForegroundManager(final Service service)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