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