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