1 /* 2 * Copyright (C) 2016 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 android.content.ContentResolver.wrap; 20 21 import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow; 22 import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL; 23 import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; 24 import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_DOCS; 25 import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_URIS; 26 import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID; 27 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION_TYPE; 28 import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN; 29 30 import android.app.Notification; 31 import android.app.Notification.Builder; 32 import android.app.PendingIntent; 33 import android.content.ContentProviderClient; 34 import android.content.ContentResolver; 35 import android.content.Context; 36 import android.content.Intent; 37 import android.net.Uri; 38 import android.os.CancellationSignal; 39 import android.os.DeadObjectException; 40 import android.os.FileUtils; 41 import android.os.Parcelable; 42 import android.os.RemoteException; 43 import android.provider.DocumentsContract; 44 import android.util.Log; 45 46 import androidx.annotation.DrawableRes; 47 import androidx.annotation.IntDef; 48 import androidx.annotation.PluralsRes; 49 50 import com.android.documentsui.Metrics; 51 import com.android.documentsui.OperationDialogFragment; 52 import com.android.documentsui.R; 53 import com.android.documentsui.base.DocumentInfo; 54 import com.android.documentsui.base.DocumentStack; 55 import com.android.documentsui.base.Features; 56 import com.android.documentsui.base.Shared; 57 import com.android.documentsui.clipping.UrisSupplier; 58 import com.android.documentsui.files.FilesActivity; 59 import com.android.documentsui.services.FileOperationService.OpType; 60 61 import java.io.FileNotFoundException; 62 import java.lang.annotation.Retention; 63 import java.lang.annotation.RetentionPolicy; 64 import java.util.ArrayList; 65 import java.util.HashMap; 66 import java.util.Map; 67 68 import javax.annotation.Nullable; 69 70 /** 71 * A mashup of work item and ui progress update factory. Used by {@link FileOperationService} 72 * to do work and show progress relating to this work. 73 */ 74 abstract public class Job implements Runnable { 75 private static final String TAG = "Job"; 76 77 @Retention(RetentionPolicy.SOURCE) 78 @IntDef({STATE_CREATED, STATE_STARTED, STATE_SET_UP, STATE_COMPLETED, STATE_CANCELED}) 79 public @interface State {} 80 public static final int STATE_CREATED = 0; 81 public static final int STATE_STARTED = 1; 82 public static final int STATE_SET_UP = 2; 83 public static final int STATE_COMPLETED = 3; 84 /** 85 * A job is in canceled state as long as {@link #cancel()} is called on it, even after it is 86 * completed. 87 */ 88 static final int STATE_CANCELED = 4; 89 90 static final String INTENT_TAG_WARNING = "warning"; 91 static final String INTENT_TAG_FAILURE = "failure"; 92 static final String INTENT_TAG_PROGRESS = "progress"; 93 static final String INTENT_TAG_CANCEL = "cancel"; 94 95 final Context service; 96 final Context appContext; 97 final Listener listener; 98 99 final @OpType int operationType; 100 final String id; 101 final DocumentStack stack; 102 103 final UrisSupplier mResourceUris; 104 105 int failureCount = 0; 106 final ArrayList<DocumentInfo> failedDocs = new ArrayList<>(); 107 final ArrayList<Uri> failedUris = new ArrayList<>(); 108 109 final Notification.Builder mProgressBuilder; 110 111 final CancellationSignal mSignal = new CancellationSignal(); 112 113 private final Map<String, ContentProviderClient> mClients = new HashMap<>(); 114 private final Features mFeatures; 115 116 private volatile @State int mState = STATE_CREATED; 117 118 /** 119 * A simple progressable job, much like an AsyncTask, but with support 120 * for providing various related notification, progress and navigation information. 121 * @param service The service context in which this job is running. 122 * @param listener 123 * @param id Arbitrary string ID 124 * @param stack The documents stack context relating to this request. This is the 125 * destination in the Files app where the user will be take when the 126 * navigation intent is invoked (presumably from notification). 127 * @param srcs the list of docs to operate on 128 */ Job(Context service, Listener listener, String id, @OpType int opType, DocumentStack stack, UrisSupplier srcs, Features features)129 Job(Context service, Listener listener, String id, 130 @OpType int opType, DocumentStack stack, UrisSupplier srcs, Features features) { 131 132 assert(opType != OPERATION_UNKNOWN); 133 134 this.service = service; 135 this.appContext = service.getApplicationContext(); 136 this.listener = listener; 137 this.operationType = opType; 138 139 this.id = id; 140 this.stack = stack; 141 this.mResourceUris = srcs; 142 143 mFeatures = features; 144 145 mProgressBuilder = createProgressBuilder(); 146 } 147 148 @Override run()149 public final void run() { 150 if (isCanceled()) { 151 // Canceled before running 152 return; 153 } 154 155 mState = STATE_STARTED; 156 listener.onStart(this); 157 158 try { 159 boolean result = setUp(); 160 if (result && !isCanceled()) { 161 mState = STATE_SET_UP; 162 start(); 163 } 164 } catch (RuntimeException e) { 165 // No exceptions should be thrown here, as all calls to the provider must be 166 // handled within Job implementations. However, just in case catch them here. 167 Log.e(TAG, "Operation failed due to an unhandled runtime exception.", e); 168 Metrics.logFileOperationErrors(operationType, failedDocs, failedUris); 169 } finally { 170 mState = (mState == STATE_STARTED || mState == STATE_SET_UP) ? STATE_COMPLETED : mState; 171 finish(); 172 listener.onFinished(this); 173 174 // NOTE: If this details is a JumboClipDetails, and it's still referred in primary clip 175 // at this point, user won't be able to paste it to anywhere else because the underlying 176 mResourceUris.dispose(); 177 } 178 } 179 setUp()180 boolean setUp() { 181 return true; 182 } 183 finish()184 abstract void finish(); 185 start()186 abstract void start(); getSetupNotification()187 abstract Notification getSetupNotification(); getProgressNotification()188 abstract Notification getProgressNotification(); getFailureNotification()189 abstract Notification getFailureNotification(); 190 getWarningNotification()191 abstract Notification getWarningNotification(); 192 getJobProgress()193 abstract JobProgress getJobProgress(); 194 getDataUriForIntent(String tag)195 Uri getDataUriForIntent(String tag) { 196 return Uri.parse(String.format("data,%s-%s", tag, id)); 197 } 198 getClient(Uri uri)199 ContentProviderClient getClient(Uri uri) throws RemoteException { 200 ContentProviderClient client = mClients.get(uri.getAuthority()); 201 if (client == null) { 202 // Acquire content providers. 203 client = acquireUnstableProviderOrThrow( 204 getContentResolver(), 205 uri.getAuthority()); 206 207 mClients.put(uri.getAuthority(), client); 208 } 209 210 assert(client != null); 211 return client; 212 } 213 getClient(DocumentInfo doc)214 ContentProviderClient getClient(DocumentInfo doc) throws RemoteException { 215 return getClient(doc.derivedUri); 216 } 217 releaseClient(Uri uri)218 void releaseClient(Uri uri) { 219 ContentProviderClient client = mClients.get(uri.getAuthority()); 220 if (client != null) { 221 client.close(); 222 mClients.remove(uri.getAuthority()); 223 } 224 } 225 releaseClient(DocumentInfo doc)226 void releaseClient(DocumentInfo doc) { 227 releaseClient(doc.derivedUri); 228 } 229 cleanup()230 final void cleanup() { 231 for (ContentProviderClient client : mClients.values()) { 232 FileUtils.closeQuietly(client); 233 } 234 } 235 getState()236 final @State int getState() { 237 return mState; 238 } 239 cancel()240 final void cancel() { 241 mState = STATE_CANCELED; 242 mSignal.cancel(); 243 Metrics.logFileOperationCancelled(operationType); 244 } 245 isCanceled()246 final boolean isCanceled() { 247 return mState == STATE_CANCELED; 248 } 249 isFinished()250 final boolean isFinished() { 251 return mState == STATE_CANCELED || mState == STATE_COMPLETED; 252 } 253 getContentResolver()254 final ContentResolver getContentResolver() { 255 return service.getContentResolver(); 256 } 257 onFileFailed(DocumentInfo file)258 void onFileFailed(DocumentInfo file) { 259 failureCount++; 260 failedDocs.add(file); 261 } 262 onResolveFailed(Uri uri)263 void onResolveFailed(Uri uri) { 264 failureCount++; 265 failedUris.add(uri); 266 } 267 hasFailures()268 final boolean hasFailures() { 269 return failureCount > 0; 270 } 271 hasWarnings()272 boolean hasWarnings() { 273 return false; 274 } 275 deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent)276 final void deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent) 277 throws ResourceException { 278 try { 279 if (parent != null && doc.isRemoveSupported()) { 280 DocumentsContract.removeDocument(wrap(getClient(doc)), doc.derivedUri, 281 parent.derivedUri); 282 } else if (doc.isDeleteSupported()) { 283 DocumentsContract.deleteDocument(wrap(getClient(doc)), doc.derivedUri); 284 } else { 285 throw new ResourceException("Unable to delete source document. " 286 + "File is not deletable or removable: %s.", doc.derivedUri); 287 } 288 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 289 if (e instanceof DeadObjectException) { 290 releaseClient(doc); 291 } 292 throw new ResourceException("Failed to delete file %s due to an exception.", 293 doc.derivedUri, e); 294 } 295 } 296 getSetupNotification(String content)297 Notification getSetupNotification(String content) { 298 mProgressBuilder.setProgress(0, 0, true) 299 .setContentText(content); 300 return mProgressBuilder.build(); 301 } 302 getFailureNotification(@luralsRes int titleId, @DrawableRes int icon)303 Notification getFailureNotification(@PluralsRes int titleId, @DrawableRes int icon) { 304 final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_FAILURE); 305 navigateIntent.putExtra(EXTRA_DIALOG_TYPE, OperationDialogFragment.DIALOG_TYPE_FAILURE); 306 navigateIntent.putExtra(EXTRA_OPERATION_TYPE, operationType); 307 navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_DOCS, failedDocs); 308 navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_URIS, failedUris); 309 310 final Notification.Builder errorBuilder = createNotificationBuilder() 311 .setContentTitle(service.getResources().getQuantityString(titleId, 312 failureCount, failureCount)) 313 .setContentText(service.getString(R.string.notification_touch_for_details)) 314 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, 315 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT 316 | PendingIntent.FLAG_MUTABLE)) 317 .setCategory(Notification.CATEGORY_ERROR) 318 .setSmallIcon(icon) 319 .setAutoCancel(true); 320 321 return errorBuilder.build(); 322 } 323 createProgressBuilder()324 abstract Builder createProgressBuilder(); 325 createProgressBuilder( String title, @DrawableRes int icon, String actionTitle, @DrawableRes int actionIcon)326 final Builder createProgressBuilder( 327 String title, @DrawableRes int icon, 328 String actionTitle, @DrawableRes int actionIcon) { 329 Notification.Builder progressBuilder = createNotificationBuilder() 330 .setContentTitle(title) 331 .setContentIntent( 332 PendingIntent.getActivity(appContext, 0, 333 buildNavigateIntent(INTENT_TAG_PROGRESS), 334 PendingIntent.FLAG_IMMUTABLE)) 335 .setCategory(Notification.CATEGORY_PROGRESS) 336 .setSmallIcon(icon) 337 .setOngoing(true); 338 339 final Intent cancelIntent = createCancelIntent(); 340 341 progressBuilder.addAction( 342 actionIcon, 343 actionTitle, 344 PendingIntent.getService( 345 service, 346 0, 347 cancelIntent, 348 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT 349 | PendingIntent.FLAG_MUTABLE)); 350 351 return progressBuilder; 352 } 353 createNotificationBuilder()354 Notification.Builder createNotificationBuilder() { 355 return mFeatures.isNotificationChannelEnabled() 356 ? new Notification.Builder(service, FileOperationService.NOTIFICATION_CHANNEL_ID) 357 : new Notification.Builder(service); 358 } 359 360 /** 361 * Creates an intent for navigating back to the destination directory. 362 */ buildNavigateIntent(String tag)363 Intent buildNavigateIntent(String tag) { 364 // TODO (b/35721285): Reuse an existing task rather than creating a new one every time. 365 Intent intent = new Intent(service, FilesActivity.class); 366 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 367 intent.setData(getDataUriForIntent(tag)); 368 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) stack); 369 return intent; 370 } 371 createCancelIntent()372 Intent createCancelIntent() { 373 final Intent cancelIntent = new Intent(service, FileOperationService.class); 374 cancelIntent.setData(getDataUriForIntent(INTENT_TAG_CANCEL)); 375 cancelIntent.putExtra(EXTRA_CANCEL, true); 376 cancelIntent.putExtra(EXTRA_JOB_ID, id); 377 return cancelIntent; 378 } 379 380 @Override toString()381 public String toString() { 382 return new StringBuilder() 383 .append("Job") 384 .append("{") 385 .append("id=" + id) 386 .append("}") 387 .toString(); 388 } 389 390 /** 391 * Listener interface employed by the service that owns us as well as tests. 392 */ 393 interface Listener { onStart(Job job)394 void onStart(Job job); onFinished(Job job)395 void onFinished(Job job); 396 } 397 398 /** 399 * Interface for tracking job progress. 400 */ 401 interface ProgressTracker { getProgress()402 default double getProgress() { return -1; } getRemainingTimeEstimate()403 default long getRemainingTimeEstimate() { 404 return -1; 405 } 406 } 407 } 408