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