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.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.Intent; 27 import android.os.Handler; 28 import android.os.IBinder; 29 import android.os.PowerManager; 30 import android.os.UserManager; 31 import android.support.annotation.VisibleForTesting; 32 import android.util.Log; 33 34 import com.android.documentsui.R; 35 import com.android.documentsui.base.Features; 36 37 import java.lang.annotation.Retention; 38 import java.lang.annotation.RetentionPolicy; 39 import java.util.ArrayList; 40 import java.util.HashMap; 41 import java.util.List; 42 import java.util.Map; 43 import java.util.concurrent.ExecutorService; 44 import java.util.concurrent.Executors; 45 import java.util.concurrent.Future; 46 import java.util.concurrent.atomic.AtomicReference; 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 private static final int NOTIFICATION_ID_PROGRESS = 0; 103 private static final int NOTIFICATION_ID_FAILURE = 1; 104 private static final int NOTIFICATION_ID_WARNING = 2; 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 HashMap<>(); 128 129 // The job whose notification is used to keep the service in foreground mode. 130 private final AtomicReference<Job> mForegroundJob = new AtomicReference<>(); 131 132 private PowerManager mPowerManager; 133 private PowerManager.WakeLock mWakeLock; // the wake lock, if held. 134 135 private int mLastServiceId; 136 137 @Override 138 public void onCreate() { 139 // Allow tests to pre-set these with test doubles. 140 if (executor == null) { 141 executor = Executors.newFixedThreadPool(POOL_SIZE); 142 } 143 144 if (deletionExecutor == null) { 145 deletionExecutor = Executors.newCachedThreadPool(); 146 } 147 148 if (handler == null) { 149 // Monitor tasks are small enough to schedule them on main thread. 150 handler = new Handler(); 151 } 152 153 if (foregroundManager == null) { 154 foregroundManager = createForegroundManager(this); 155 } 156 157 if (notificationManager == null) { 158 notificationManager = getSystemService(NotificationManager.class); 159 } 160 161 features = new Features.RuntimeFeatures(getResources(), UserManager.get(this)); 162 setUpNotificationChannel(); 163 164 if (DEBUG) Log.d(TAG, "Created."); 165 mPowerManager = getSystemService(PowerManager.class); 166 } 167 168 private void setUpNotificationChannel() { 169 if (features.isNotificationChannelEnabled()) { 170 NotificationChannel channel = new NotificationChannel( 171 NOTIFICATION_CHANNEL_ID, 172 getString(R.string.app_label), 173 NotificationManager.IMPORTANCE_LOW); 174 notificationManager.createNotificationChannel(channel); 175 } 176 } 177 178 @Override 179 public void onDestroy() { 180 if (DEBUG) Log.d(TAG, "Shutting down executor."); 181 182 List<Runnable> unfinishedCopies = executor.shutdownNow(); 183 List<Runnable> unfinishedDeletions = deletionExecutor.shutdownNow(); 184 List<Runnable> unfinished = 185 new ArrayList<>(unfinishedCopies.size() + unfinishedDeletions.size()); 186 unfinished.addAll(unfinishedCopies); 187 unfinished.addAll(unfinishedDeletions); 188 if (!unfinished.isEmpty()) { 189 Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished); 190 } 191 192 executor = null; 193 deletionExecutor = null; 194 handler = null; 195 196 if (DEBUG) Log.d(TAG, "Destroyed."); 197 } 198 199 @Override 200 public int onStartCommand(Intent intent, int flags, int serviceId) { 201 // TODO: Ensure we're not being called with retry or redeliver. 202 // checkArgument(flags == 0); // retry and redeliver are not supported. 203 204 String jobId = intent.getStringExtra(EXTRA_JOB_ID); 205 assert(jobId != null); 206 207 if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId); 208 209 if (intent.hasExtra(EXTRA_CANCEL)) { 210 handleCancel(intent); 211 } else { 212 FileOperation operation = intent.getParcelableExtra(EXTRA_OPERATION); 213 handleOperation(jobId, operation); 214 } 215 216 // Track the service supplied id so we can stop the service once we're out of work to do. 217 mLastServiceId = serviceId; 218 219 return START_NOT_STICKY; 220 } 221 222 private void handleOperation(String jobId, FileOperation operation) { 223 synchronized (mJobs) { 224 if (mWakeLock == null) { 225 mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); 226 } 227 228 if (mJobs.containsKey(jobId)) { 229 Log.w(TAG, "Duplicate job id: " + jobId 230 + ". Ignoring job request for operation: " + operation + "."); 231 return; 232 } 233 234 Job job = operation.createJob(this, this, jobId, features); 235 236 if (job == null) { 237 return; 238 } 239 240 assert (job != null); 241 if (DEBUG) Log.d(TAG, "Scheduling job " + job.id + "."); 242 Future<?> future = getExecutorService(operation.getOpType()).submit(job); 243 mJobs.put(jobId, new JobRecord(job, future)); 244 245 // Acquire wake lock to keep CPU running until we finish all jobs. Acquire wake lock 246 // after we create a job and put it in mJobs to avoid potential leaking of wake lock 247 // in case where job creation fails. 248 mWakeLock.acquire(); 249 } 250 } 251 252 /** 253 * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID". 254 * 255 * @param intent The cancellation intent. 256 */ 257 private void handleCancel(Intent intent) { 258 assert(intent.hasExtra(EXTRA_CANCEL)); 259 assert(intent.getStringExtra(EXTRA_JOB_ID) != null); 260 261 String jobId = intent.getStringExtra(EXTRA_JOB_ID); 262 263 if (DEBUG) Log.d(TAG, "handleCancel: " + jobId); 264 265 synchronized (mJobs) { 266 // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey 267 // cancellation requests from affecting unrelated copy jobs. However, if the current job ID 268 // is null, the service most likely crashed and was revived by the incoming cancel intent. 269 // In that case, always allow the cancellation to proceed. 270 JobRecord record = mJobs.get(jobId); 271 if (record != null) { 272 record.job.cancel(); 273 } 274 } 275 276 // Dismiss the progress notification here rather than in the copy loop. This preserves 277 // interactivity for the user in case the copy loop is stalled. 278 // Try to cancel it even if we don't have a job id...in case there is some sad 279 // orphan notification. 280 notificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS); 281 282 // TODO: Guarantee the job is being finalized 283 } 284 285 private ExecutorService getExecutorService(@OpType int operationType) { 286 switch (operationType) { 287 case OPERATION_COPY: 288 case OPERATION_COMPRESS: 289 case OPERATION_EXTRACT: 290 case OPERATION_MOVE: 291 return executor; 292 case OPERATION_DELETE: 293 return deletionExecutor; 294 default: 295 throw new UnsupportedOperationException(); 296 } 297 } 298 299 @GuardedBy("mJobs") 300 private void deleteJob(Job job) { 301 if (DEBUG) Log.d(TAG, "deleteJob: " + job.id); 302 303 // Release wake lock before clearing jobs just in case we fail to clean them up. 304 mWakeLock.release(); 305 if (!mWakeLock.isHeld()) { 306 mWakeLock = null; 307 } 308 309 JobRecord record = mJobs.remove(job.id); 310 assert(record != null); 311 record.job.cleanup(); 312 313 // Delay the shutdown until we've cleaned up all notifications. shutdown() is now posted in 314 // onFinished(Job job) to main thread. 315 } 316 317 /** 318 * Most likely shuts down. Won't shut down if service has a pending 319 * message. Thread pool is deal with in onDestroy. 320 */ 321 private void shutdown() { 322 if (DEBUG) Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId); 323 assert(mWakeLock == null); 324 325 // Turns out, for us, stopSelfResult always returns false in tests, 326 // so we can't guard executor shutdown. For this reason we move 327 // executor shutdown to #onDestroy. 328 boolean gonnaStop = stopSelfResult(mLastServiceId); 329 if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop); 330 if (!gonnaStop) { 331 Log.w(TAG, "Service should be stopping, but reports otherwise."); 332 } 333 } 334 335 @VisibleForTesting 336 boolean holdsWakeLock() { 337 return mWakeLock != null && mWakeLock.isHeld(); 338 } 339 340 @Override 341 public void onStart(Job job) { 342 if (DEBUG) Log.d(TAG, "onStart: " + job.id); 343 344 Notification notification = job.getSetupNotification(); 345 // If there is no foreground job yet, set this job to foreground job. 346 if (mForegroundJob.compareAndSet(null, job)) { 347 if (DEBUG) Log.d(TAG, "Set foreground job to " + job.id); 348 foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification); 349 } 350 351 // Show start up notification 352 if (DEBUG) Log.d(TAG, "Posting notification for " + job.id); 353 notificationManager.notify( 354 job.id, NOTIFICATION_ID_PROGRESS, notification); 355 356 // Set up related monitor 357 JobMonitor monitor = new JobMonitor(job, notificationManager, handler, mJobs); 358 monitor.start(); 359 } 360 361 @Override 362 public void onFinished(Job job) { 363 assert(job.isFinished()); 364 if (DEBUG) Log.d(TAG, "onFinished: " + job.id); 365 366 synchronized (mJobs) { 367 // Delete the job from mJobs first to avoid this job being selected as the foreground 368 // task again if we need to swap the foreground job. 369 deleteJob(job); 370 371 // Update foreground state before cleaning up notification. If the finishing job is the 372 // foreground job, we would need to switch to another one or go to background before 373 // we can clean up notifications. 374 updateForegroundState(job); 375 376 // Use the same thread of monitors to tackle notifications to avoid race conditions. 377 // Otherwise we may fail to dismiss progress notification. 378 handler.post(() -> cleanUpNotification(job)); 379 380 // Post the shutdown message to main thread after cleanUpNotification() to give it a 381 // chance to run. Otherwise this process may be torn down by Android before we've 382 // cleaned up the notifications of the last job. 383 if (mJobs.isEmpty()) { 384 handler.post(this::shutdown); 385 } 386 } 387 } 388 389 @GuardedBy("mJobs") 390 private void updateForegroundState(Job job) { 391 Job candidate = mJobs.isEmpty() ? null : mJobs.values().iterator().next().job; 392 393 // If foreground job is retiring and there is still work to do, we need to set it to a new 394 // job. 395 if (mForegroundJob.compareAndSet(job, candidate)) { 396 if (candidate == null) { 397 if (DEBUG) Log.d(TAG, "Stop foreground"); 398 // Remove the notification here just in case we're torn down before we have the 399 // chance to clean up notifications. 400 foregroundManager.stopForeground(true); 401 } else { 402 if (DEBUG) Log.d(TAG, "Switch foreground job to " + candidate.id); 403 404 Notification notification = (candidate.getState() == Job.STATE_STARTED) 405 ? candidate.getSetupNotification() 406 : candidate.getProgressNotification(); 407 foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification); 408 notificationManager.notify(candidate.id, NOTIFICATION_ID_PROGRESS, 409 notification); 410 } 411 } 412 } 413 414 private void cleanUpNotification(Job job) { 415 416 if (DEBUG) Log.d(TAG, "Canceling notification for " + job.id); 417 // Dismiss the ongoing copy notification when the copy is done. 418 notificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS); 419 420 if (job.hasFailures()) { 421 if (!job.failedUris.isEmpty()) { 422 Log.e(TAG, "Job failed to resolve uris: " + job.failedUris + "."); 423 } 424 if (!job.failedDocs.isEmpty()) { 425 Log.e(TAG, "Job failed to process docs: " + job.failedDocs + "."); 426 } 427 notificationManager.notify( 428 job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification()); 429 } 430 431 if (job.hasWarnings()) { 432 if (DEBUG) Log.d(TAG, "Job finished with warnings."); 433 notificationManager.notify( 434 job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification()); 435 } 436 } 437 438 private static final class JobRecord { 439 private final Job job; 440 private final Future<?> future; 441 442 public JobRecord(Job job, Future<?> future) { 443 this.job = job; 444 this.future = future; 445 } 446 } 447 448 /** 449 * A class used to periodically polls state of a job. 450 * 451 * <p>It's possible that jobs hang because underlying document providers stop responding. We 452 * still need to update notifications if jobs hang, so instead of jobs pushing their states, 453 * we poll states of jobs. 454 */ 455 private static final class JobMonitor implements Runnable { 456 private static final long PROGRESS_INTERVAL_MILLIS = 500L; 457 458 private final Job mJob; 459 private final NotificationManager mNotificationManager; 460 private final Handler mHandler; 461 private final Object mJobsLock; 462 463 private JobMonitor(Job job, NotificationManager notificationManager, Handler handler, 464 Object jobsLock) { 465 mJob = job; 466 mNotificationManager = notificationManager; 467 mHandler = handler; 468 mJobsLock = jobsLock; 469 } 470 471 private void start() { 472 mHandler.post(this); 473 } 474 475 @Override 476 public void run() { 477 synchronized (mJobsLock) { 478 if (mJob.isFinished()) { 479 // Finish notification is already shown. Progress notification is removed. 480 // Just finish itself. 481 return; 482 } 483 484 // Only job in set up state has progress bar 485 if (mJob.getState() == Job.STATE_SET_UP) { 486 mNotificationManager.notify( 487 mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification()); 488 } 489 490 mHandler.postDelayed(this, PROGRESS_INTERVAL_MILLIS); 491 } 492 } 493 } 494 495 @Override 496 public IBinder onBind(Intent intent) { 497 return null; // Boilerplate. See super#onBind 498 } 499 500 private static ForegroundManager createForegroundManager(final Service service) { 501 return new ForegroundManager() { 502 @Override 503 public void startForeground(int id, Notification notification) { 504 service.startForeground(id, notification); 505 } 506 507 @Override 508 public void stopForeground(boolean removeNotification) { 509 service.stopForeground(removeNotification); 510 } 511 }; 512 } 513 514 @VisibleForTesting 515 interface ForegroundManager { 516 void startForeground(int id, Notification notification); 517 void stopForeground(boolean removeNotification); 518 } 519 } 520