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 @interface State {} 80 static final int STATE_CREATED = 0; 81 static final int STATE_STARTED = 1; 82 static final int STATE_SET_UP = 2; 83 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 getDataUriForIntent(String tag)193 Uri getDataUriForIntent(String tag) { 194 return Uri.parse(String.format("data,%s-%s", tag, id)); 195 } 196 getClient(Uri uri)197 ContentProviderClient getClient(Uri uri) throws RemoteException { 198 ContentProviderClient client = mClients.get(uri.getAuthority()); 199 if (client == null) { 200 // Acquire content providers. 201 client = acquireUnstableProviderOrThrow( 202 getContentResolver(), 203 uri.getAuthority()); 204 205 mClients.put(uri.getAuthority(), client); 206 } 207 208 assert(client != null); 209 return client; 210 } 211 getClient(DocumentInfo doc)212 ContentProviderClient getClient(DocumentInfo doc) throws RemoteException { 213 return getClient(doc.derivedUri); 214 } 215 releaseClient(Uri uri)216 void releaseClient(Uri uri) { 217 ContentProviderClient client = mClients.get(uri.getAuthority()); 218 if (client != null) { 219 client.close(); 220 mClients.remove(uri.getAuthority()); 221 } 222 } 223 releaseClient(DocumentInfo doc)224 void releaseClient(DocumentInfo doc) { 225 releaseClient(doc.derivedUri); 226 } 227 cleanup()228 final void cleanup() { 229 for (ContentProviderClient client : mClients.values()) { 230 FileUtils.closeQuietly(client); 231 } 232 } 233 getState()234 final @State int getState() { 235 return mState; 236 } 237 cancel()238 final void cancel() { 239 mState = STATE_CANCELED; 240 mSignal.cancel(); 241 Metrics.logFileOperationCancelled(operationType); 242 } 243 isCanceled()244 final boolean isCanceled() { 245 return mState == STATE_CANCELED; 246 } 247 isFinished()248 final boolean isFinished() { 249 return mState == STATE_CANCELED || mState == STATE_COMPLETED; 250 } 251 getContentResolver()252 final ContentResolver getContentResolver() { 253 return service.getContentResolver(); 254 } 255 onFileFailed(DocumentInfo file)256 void onFileFailed(DocumentInfo file) { 257 failureCount++; 258 failedDocs.add(file); 259 } 260 onResolveFailed(Uri uri)261 void onResolveFailed(Uri uri) { 262 failureCount++; 263 failedUris.add(uri); 264 } 265 hasFailures()266 final boolean hasFailures() { 267 return failureCount > 0; 268 } 269 hasWarnings()270 boolean hasWarnings() { 271 return false; 272 } 273 deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent)274 final void deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent) 275 throws ResourceException { 276 try { 277 if (parent != null && doc.isRemoveSupported()) { 278 DocumentsContract.removeDocument(wrap(getClient(doc)), doc.derivedUri, 279 parent.derivedUri); 280 } else if (doc.isDeleteSupported()) { 281 DocumentsContract.deleteDocument(wrap(getClient(doc)), doc.derivedUri); 282 } else { 283 throw new ResourceException("Unable to delete source document. " 284 + "File is not deletable or removable: %s.", doc.derivedUri); 285 } 286 } catch (FileNotFoundException | RemoteException | RuntimeException e) { 287 if (e instanceof DeadObjectException) { 288 releaseClient(doc); 289 } 290 throw new ResourceException("Failed to delete file %s due to an exception.", 291 doc.derivedUri, e); 292 } 293 } 294 getSetupNotification(String content)295 Notification getSetupNotification(String content) { 296 mProgressBuilder.setProgress(0, 0, true) 297 .setContentText(content); 298 return mProgressBuilder.build(); 299 } 300 getFailureNotification(@luralsRes int titleId, @DrawableRes int icon)301 Notification getFailureNotification(@PluralsRes int titleId, @DrawableRes int icon) { 302 final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_FAILURE); 303 navigateIntent.putExtra(EXTRA_DIALOG_TYPE, OperationDialogFragment.DIALOG_TYPE_FAILURE); 304 navigateIntent.putExtra(EXTRA_OPERATION_TYPE, operationType); 305 navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_DOCS, failedDocs); 306 navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_URIS, failedUris); 307 308 final Notification.Builder errorBuilder = createNotificationBuilder() 309 .setContentTitle(service.getResources().getQuantityString(titleId, 310 failureCount, failureCount)) 311 .setContentText(service.getString(R.string.notification_touch_for_details)) 312 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, 313 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT 314 | PendingIntent.FLAG_MUTABLE)) 315 .setCategory(Notification.CATEGORY_ERROR) 316 .setSmallIcon(icon) 317 .setAutoCancel(true); 318 319 return errorBuilder.build(); 320 } 321 createProgressBuilder()322 abstract Builder createProgressBuilder(); 323 createProgressBuilder( String title, @DrawableRes int icon, String actionTitle, @DrawableRes int actionIcon)324 final Builder createProgressBuilder( 325 String title, @DrawableRes int icon, 326 String actionTitle, @DrawableRes int actionIcon) { 327 Notification.Builder progressBuilder = createNotificationBuilder() 328 .setContentTitle(title) 329 .setContentIntent( 330 PendingIntent.getActivity(appContext, 0, 331 buildNavigateIntent(INTENT_TAG_PROGRESS), 332 PendingIntent.FLAG_IMMUTABLE)) 333 .setCategory(Notification.CATEGORY_PROGRESS) 334 .setSmallIcon(icon) 335 .setOngoing(true); 336 337 final Intent cancelIntent = createCancelIntent(); 338 339 progressBuilder.addAction( 340 actionIcon, 341 actionTitle, 342 PendingIntent.getService( 343 service, 344 0, 345 cancelIntent, 346 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT 347 | PendingIntent.FLAG_MUTABLE)); 348 349 return progressBuilder; 350 } 351 createNotificationBuilder()352 Notification.Builder createNotificationBuilder() { 353 return mFeatures.isNotificationChannelEnabled() 354 ? new Notification.Builder(service, FileOperationService.NOTIFICATION_CHANNEL_ID) 355 : new Notification.Builder(service); 356 } 357 358 /** 359 * Creates an intent for navigating back to the destination directory. 360 */ buildNavigateIntent(String tag)361 Intent buildNavigateIntent(String tag) { 362 // TODO (b/35721285): Reuse an existing task rather than creating a new one every time. 363 Intent intent = new Intent(service, FilesActivity.class); 364 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 365 intent.setData(getDataUriForIntent(tag)); 366 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) stack); 367 return intent; 368 } 369 createCancelIntent()370 Intent createCancelIntent() { 371 final Intent cancelIntent = new Intent(service, FileOperationService.class); 372 cancelIntent.setData(getDataUriForIntent(INTENT_TAG_CANCEL)); 373 cancelIntent.putExtra(EXTRA_CANCEL, true); 374 cancelIntent.putExtra(EXTRA_JOB_ID, id); 375 return cancelIntent; 376 } 377 378 @Override toString()379 public String toString() { 380 return new StringBuilder() 381 .append("Job") 382 .append("{") 383 .append("id=" + id) 384 .append("}") 385 .toString(); 386 } 387 388 /** 389 * Listener interface employed by the service that owns us as well as tests. 390 */ 391 interface Listener { onStart(Job job)392 void onStart(Job job); onFinished(Job job)393 void onFinished(Job job); 394 } 395 396 /** 397 * Interface for tracking job progress. 398 */ 399 interface ProgressTracker { getProgress()400 default double getProgress() { return -1; } getRemainingTimeEstimate()401 default long getRemainingTimeEstimate() { 402 return -1; 403 } 404 } 405 } 406