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.Shared.DEBUG; 20 21 import android.annotation.IntDef; 22 import android.app.NotificationManager; 23 import android.app.Service; 24 import android.content.Intent; 25 import android.os.IBinder; 26 import android.os.PowerManager; 27 import android.support.annotation.Nullable; 28 import android.support.annotation.VisibleForTesting; 29 import android.util.Log; 30 31 import com.android.documentsui.Shared; 32 import com.android.documentsui.model.DocumentInfo; 33 import com.android.documentsui.model.DocumentStack; 34 import com.android.documentsui.services.Job.Factory; 35 36 import java.lang.annotation.Retention; 37 import java.lang.annotation.RetentionPolicy; 38 import java.util.HashMap; 39 import java.util.List; 40 import java.util.Map; 41 import java.util.concurrent.ScheduledExecutorService; 42 import java.util.concurrent.ScheduledFuture; 43 import java.util.concurrent.ScheduledThreadPoolExecutor; 44 import java.util.concurrent.TimeUnit; 45 46 import javax.annotation.concurrent.GuardedBy; 47 48 public class FileOperationService extends Service implements Job.Listener { 49 50 private static final int DEFAULT_DELAY = 0; 51 private static final int MAX_DELAY = 10 * 1000; // ten seconds 52 private static final int POOL_SIZE = 2; // "pool size", not *max* "pool size". 53 private static final int NOTIFICATION_ID_PROGRESS = 0; 54 private static final int NOTIFICATION_ID_FAILURE = 1; 55 private static final int NOTIFICATION_ID_WARNING = 2; 56 57 public static final String TAG = "FileOperationService"; 58 59 public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID"; 60 public static final String EXTRA_DELAY = "com.android.documentsui.DELAY"; 61 public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION"; 62 public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; 63 public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; 64 public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE"; 65 66 // This extra is used only for moving and deleting. Currently it's not the case, 67 // but in the future those files may be from multiple different parents. In 68 // such case, this needs to be replaced with pairs of parent and child. 69 public static final String EXTRA_SRC_PARENT = "com.android.documentsui.SRC_PARENT"; 70 71 @IntDef(flag = true, value = { 72 OPERATION_UNKNOWN, 73 OPERATION_COPY, 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_MOVE = 2; 82 public static final int OPERATION_DELETE = 3; 83 84 // TODO: Move it to a shared file when more operations are implemented. 85 public static final int FAILURE_COPY = 1; 86 87 // The executor and job factory are visible for testing and non-final 88 // so we'll have a way to inject test doubles from the test. It's 89 // a sub-optimal arrangement. 90 @VisibleForTesting ScheduledExecutorService executor; 91 @VisibleForTesting Factory jobFactory; 92 93 private PowerManager mPowerManager; 94 private PowerManager.WakeLock mWakeLock; // the wake lock, if held. 95 private NotificationManager mNotificationManager; 96 97 @GuardedBy("mRunning") 98 private Map<String, JobRecord> mRunning = new HashMap<>(); 99 100 private int mLastServiceId; 101 102 @Override 103 public void onCreate() { 104 // Allow tests to pre-set these with test doubles. 105 if (executor == null) { 106 executor = new ScheduledThreadPoolExecutor(POOL_SIZE); 107 } 108 109 if (jobFactory == null) { 110 jobFactory = Job.Factory.instance; 111 } 112 113 if (DEBUG) Log.d(TAG, "Created."); 114 mPowerManager = getSystemService(PowerManager.class); 115 mNotificationManager = getSystemService(NotificationManager.class); 116 } 117 118 @Override 119 public void onDestroy() { 120 if (DEBUG) Log.d(TAG, "Shutting down executor."); 121 List<Runnable> unfinished = executor.shutdownNow(); 122 if (!unfinished.isEmpty()) { 123 Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished); 124 } 125 executor = null; 126 if (DEBUG) Log.d(TAG, "Destroyed."); 127 } 128 129 @Override 130 public int onStartCommand(Intent intent, int flags, int serviceId) { 131 // TODO: Ensure we're not being called with retry or redeliver. 132 // checkArgument(flags == 0); // retry and redeliver are not supported. 133 134 String jobId = intent.getStringExtra(EXTRA_JOB_ID); 135 @OpType int operationType = intent.getIntExtra(EXTRA_OPERATION, OPERATION_UNKNOWN); 136 assert(jobId != null); 137 138 if (intent.hasExtra(EXTRA_CANCEL)) { 139 handleCancel(intent); 140 } else { 141 assert(operationType != OPERATION_UNKNOWN); 142 handleOperation(intent, serviceId, jobId, operationType); 143 } 144 145 return START_NOT_STICKY; 146 } 147 148 private void handleOperation(Intent intent, int serviceId, String jobId, int operationType) { 149 if (DEBUG) Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId); 150 151 // Track the service supplied id so we can stop the service once we're out of work to do. 152 mLastServiceId = serviceId; 153 154 Job job = null; 155 synchronized (mRunning) { 156 if (mWakeLock == null) { 157 mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); 158 } 159 160 List<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST); 161 DocumentInfo srcParent = intent.getParcelableExtra(EXTRA_SRC_PARENT); 162 DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK); 163 164 job = createJob(operationType, jobId, srcs, srcParent, stack); 165 166 if (job == null) { 167 return; 168 } 169 170 mWakeLock.acquire(); 171 } 172 173 assert(job != null); 174 int delay = intent.getIntExtra(EXTRA_DELAY, DEFAULT_DELAY); 175 assert(delay <= MAX_DELAY); 176 if (DEBUG) Log.d( 177 TAG, "Scheduling job " + job.id + " to run in " + delay + " milliseconds."); 178 ScheduledFuture<?> future = executor.schedule(job, delay, TimeUnit.MILLISECONDS); 179 mRunning.put(jobId, new JobRecord(job, future)); 180 } 181 182 /** 183 * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID". 184 * 185 * @param intent The cancellation intent. 186 */ 187 private void handleCancel(Intent intent) { 188 assert(intent.hasExtra(EXTRA_CANCEL)); 189 assert(intent.getStringExtra(EXTRA_JOB_ID) != null); 190 191 String jobId = intent.getStringExtra(EXTRA_JOB_ID); 192 193 if (DEBUG) Log.d(TAG, "handleCancel: " + jobId); 194 195 synchronized (mRunning) { 196 // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey 197 // cancellation requests from affecting unrelated copy jobs. However, if the current job ID 198 // is null, the service most likely crashed and was revived by the incoming cancel intent. 199 // In that case, always allow the cancellation to proceed. 200 JobRecord record = mRunning.get(jobId); 201 if (record != null) { 202 record.job.cancel(); 203 204 // If the job hasn't been started, cancel it and explicitly clean up. 205 // If it *has* been started, we wait for it to recognize this, then 206 // allow it stop working in an orderly fashion. 207 if (record.future.getDelay(TimeUnit.MILLISECONDS) > 0) { 208 record.future.cancel(false); 209 onFinished(record.job); 210 } 211 } 212 } 213 214 // Dismiss the progress notification here rather than in the copy loop. This preserves 215 // interactivity for the user in case the copy loop is stalled. 216 // Try to cancel it even if we don't have a job id...in case there is some sad 217 // orphan notification. 218 mNotificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS); 219 220 // TODO: Guarantee the job is being finalized 221 } 222 223 /** 224 * Creates a new job. Returns null if a job with {@code id} already exists. 225 * @return 226 */ 227 @GuardedBy("mRunning") 228 private @Nullable Job createJob( 229 @OpType int operationType, String id, List<DocumentInfo> srcs, DocumentInfo srcParent, 230 DocumentStack stack) { 231 232 if (srcs.isEmpty()) { 233 Log.w(TAG, "Ignoring job request with empty srcs list. Id: " + id); 234 return null; 235 } 236 237 if (mRunning.containsKey(id)) { 238 Log.w(TAG, "Duplicate job id: " + id 239 + ". Ignoring job request for srcs: " + srcs + ", stack: " + stack + "."); 240 return null; 241 } 242 243 switch (operationType) { 244 case OPERATION_COPY: 245 return jobFactory.createCopy( 246 this, getApplicationContext(), this, id, stack, srcs); 247 case OPERATION_MOVE: 248 return jobFactory.createMove( 249 this, getApplicationContext(), this, id, stack, srcs, 250 srcParent); 251 case OPERATION_DELETE: 252 return jobFactory.createDelete( 253 this, getApplicationContext(), this, id, stack, srcs, 254 srcParent); 255 default: 256 throw new UnsupportedOperationException(); 257 } 258 } 259 260 @GuardedBy("mRunning") 261 private void deleteJob(Job job) { 262 if (DEBUG) Log.d(TAG, "deleteJob: " + job.id); 263 264 JobRecord record = mRunning.remove(job.id); 265 assert(record != null); 266 record.job.cleanup(); 267 268 if (mRunning.isEmpty()) { 269 shutdown(); 270 } 271 } 272 273 /** 274 * Most likely shuts down. Won't shut down if service has a pending 275 * message. Thread pool is deal with in onDestroy. 276 */ 277 private void shutdown() { 278 if (DEBUG) Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId); 279 mWakeLock.release(); 280 mWakeLock = null; 281 282 // Turns out, for us, stopSelfResult always returns false in tests, 283 // so we can't guard executor shutdown. For this reason we move 284 // executor shutdown to #onDestroy. 285 boolean gonnaStop = stopSelfResult(mLastServiceId); 286 if (DEBUG) Log.d(TAG, "Stopping service: " + gonnaStop); 287 if (!gonnaStop) { 288 Log.w(TAG, "Service should be stopping, but reports otherwise."); 289 } 290 } 291 292 @VisibleForTesting 293 boolean holdsWakeLock() { 294 return mWakeLock != null && mWakeLock.isHeld(); 295 } 296 297 @Override 298 public void onStart(Job job) { 299 if (DEBUG) Log.d(TAG, "onStart: " + job.id); 300 mNotificationManager.notify(job.id, NOTIFICATION_ID_PROGRESS, job.getSetupNotification()); 301 } 302 303 @Override 304 public void onFinished(Job job) { 305 if (DEBUG) Log.d(TAG, "onFinished: " + job.id); 306 307 // Dismiss the ongoing copy notification when the copy is done. 308 mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS); 309 310 if (job.hasFailures()) { 311 Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + "."); 312 mNotificationManager.notify( 313 job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification()); 314 } 315 316 if (job.hasWarnings()) { 317 if (DEBUG) Log.d(TAG, "Job finished with warnings."); 318 mNotificationManager.notify( 319 job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification()); 320 } 321 322 synchronized (mRunning) { 323 deleteJob(job); 324 } 325 } 326 327 @Override 328 public void onProgress(CopyJob job) { 329 if (DEBUG) Log.d(TAG, "onProgress: " + job.id); 330 mNotificationManager.notify( 331 job.id, NOTIFICATION_ID_PROGRESS, job.getProgressNotification()); 332 } 333 334 private static final class JobRecord { 335 private final Job job; 336 private final ScheduledFuture<?> future; 337 338 public JobRecord(Job job, ScheduledFuture<?> future) { 339 this.job = job; 340 this.future = future; 341 } 342 } 343 344 @Override 345 public IBinder onBind(Intent intent) { 346 return null; // Boilerplate. See super#onBind 347 } 348 } 349