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