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