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