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