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