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