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