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 17package com.android.documentsui.services; 18 19import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow; 20import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL; 21import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; 22import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID; 23import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION; 24import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST; 25import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN; 26 27import android.annotation.DrawableRes; 28import android.annotation.PluralsRes; 29import android.app.Notification; 30import android.app.Notification.Builder; 31import android.app.PendingIntent; 32import android.content.ContentProviderClient; 33import android.content.ContentResolver; 34import android.content.Context; 35import android.content.Intent; 36import android.net.Uri; 37import android.os.Parcelable; 38import android.os.RemoteException; 39import android.provider.DocumentsContract; 40import android.util.Log; 41 42import com.android.documentsui.FilesActivity; 43import com.android.documentsui.Metrics; 44import com.android.documentsui.OperationDialogFragment; 45import com.android.documentsui.R; 46import com.android.documentsui.Shared; 47import com.android.documentsui.model.DocumentInfo; 48import com.android.documentsui.model.DocumentStack; 49import com.android.documentsui.services.FileOperationService.OpType; 50 51import java.util.ArrayList; 52import java.util.HashMap; 53import java.util.List; 54import java.util.Map; 55 56/** 57 * A mashup of work item and ui progress update factory. Used by {@link FileOperationService} 58 * to do work and show progress relating to this work. 59 */ 60abstract public class Job implements Runnable { 61 private static final String TAG = "Job"; 62 63 static final String INTENT_TAG_WARNING = "warning"; 64 static final String INTENT_TAG_FAILURE = "failure"; 65 static final String INTENT_TAG_PROGRESS = "progress"; 66 static final String INTENT_TAG_CANCEL = "cancel"; 67 68 final Context service; 69 final Context appContext; 70 final Listener listener; 71 72 final @OpType int operationType; 73 final String id; 74 final DocumentStack stack; 75 76 final ArrayList<DocumentInfo> failedFiles = new ArrayList<>(); 77 final Notification.Builder mProgressBuilder; 78 79 private final Map<String, ContentProviderClient> mClients = new HashMap<>(); 80 private volatile boolean mCanceled; 81 82 /** 83 * A simple progressable job, much like an AsyncTask, but with support 84 * for providing various related notification, progress and navigation information. 85 * @param operationType 86 * 87 * @param service The service context in which this job is running. 88 * @param appContext The context of the invoking application. This is usually 89 * just {@code getApplicationContext()}. 90 * @param listener 91 * @param id Arbitrary string ID 92 * @param stack The documents stack context relating to this request. This is the 93 * destination in the Files app where the user will be take when the 94 * navigation intent is invoked (presumably from notification). 95 */ 96 Job(Context service, Context appContext, Listener listener, 97 @OpType int operationType, String id, DocumentStack stack) { 98 99 assert(operationType != OPERATION_UNKNOWN); 100 101 this.service = service; 102 this.appContext = appContext; 103 this.listener = listener; 104 this.operationType = operationType; 105 106 this.id = id; 107 this.stack = stack; 108 109 mProgressBuilder = createProgressBuilder(); 110 } 111 112 @Override 113 public final void run() { 114 listener.onStart(this); 115 try { 116 start(); 117 } catch (RuntimeException e) { 118 // No exceptions should be thrown here, as all calls to the provider must be 119 // handled within Job implementations. However, just in case catch them here. 120 Log.e(TAG, "Operation failed due to an unhandled runtime exception.", e); 121 Metrics.logFileOperationErrors(service, operationType, failedFiles); 122 } finally { 123 listener.onFinished(this); 124 } 125 } 126 127 abstract void start(); 128 129 abstract Notification getSetupNotification(); 130 // TODO: Progress notification for deletes. 131 // abstract Notification getProgressNotification(long bytesCopied); 132 abstract Notification getFailureNotification(); 133 134 abstract Notification getWarningNotification(); 135 136 Uri getDataUriForIntent(String tag) { 137 return Uri.parse(String.format("data,%s-%s", tag, id)); 138 } 139 140 ContentProviderClient getClient(DocumentInfo doc) throws RemoteException { 141 ContentProviderClient client = mClients.get(doc.authority); 142 if (client == null) { 143 // Acquire content providers. 144 client = acquireUnstableProviderOrThrow( 145 getContentResolver(), 146 doc.authority); 147 148 mClients.put(doc.authority, client); 149 } 150 151 assert(client != null); 152 return client; 153 } 154 155 final void cleanup() { 156 for (ContentProviderClient client : mClients.values()) { 157 ContentProviderClient.releaseQuietly(client); 158 } 159 } 160 161 final void cancel() { 162 mCanceled = true; 163 Metrics.logFileOperationCancelled(service, operationType); 164 } 165 166 final boolean isCanceled() { 167 return mCanceled; 168 } 169 170 final ContentResolver getContentResolver() { 171 return service.getContentResolver(); 172 } 173 174 void onFileFailed(DocumentInfo file) { 175 failedFiles.add(file); 176 } 177 178 final boolean hasFailures() { 179 return !failedFiles.isEmpty(); 180 } 181 182 boolean hasWarnings() { 183 return false; 184 } 185 186 final void deleteDocument(DocumentInfo doc, DocumentInfo parent) throws ResourceException { 187 try { 188 if (doc.isRemoveSupported()) { 189 DocumentsContract.removeDocument(getClient(doc), doc.derivedUri, parent.derivedUri); 190 } else if (doc.isDeleteSupported()) { 191 DocumentsContract.deleteDocument(getClient(doc), doc.derivedUri); 192 } else { 193 throw new ResourceException("Unable to delete source document as the file is " + 194 "not deletable nor removable: %s.", doc.derivedUri); 195 } 196 } catch (RemoteException | RuntimeException e) { 197 throw new ResourceException("Failed to delete file %s due to an exception.", 198 doc.derivedUri, e); 199 } 200 } 201 202 Notification getSetupNotification(String content) { 203 mProgressBuilder.setProgress(0, 0, true) 204 .setContentText(content); 205 return mProgressBuilder.build(); 206 } 207 208 Notification getFailureNotification(@PluralsRes int titleId, @DrawableRes int icon) { 209 final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_FAILURE); 210 navigateIntent.putExtra(EXTRA_DIALOG_TYPE, OperationDialogFragment.DIALOG_TYPE_FAILURE); 211 navigateIntent.putExtra(EXTRA_OPERATION, operationType); 212 navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, failedFiles); 213 214 final Notification.Builder errorBuilder = new Notification.Builder(service) 215 .setContentTitle(service.getResources().getQuantityString(titleId, 216 failedFiles.size(), failedFiles.size())) 217 .setContentText(service.getString(R.string.notification_touch_for_details)) 218 .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, 219 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT)) 220 .setCategory(Notification.CATEGORY_ERROR) 221 .setSmallIcon(icon) 222 .setAutoCancel(true); 223 224 return errorBuilder.build(); 225 } 226 227 abstract Builder createProgressBuilder(); 228 229 final Builder createProgressBuilder( 230 String title, @DrawableRes int icon, 231 String actionTitle, @DrawableRes int actionIcon) { 232 Notification.Builder progressBuilder = new Notification.Builder(service) 233 .setContentTitle(title) 234 .setContentIntent( 235 PendingIntent.getActivity(appContext, 0, 236 buildNavigateIntent(INTENT_TAG_PROGRESS), 0)) 237 .setCategory(Notification.CATEGORY_PROGRESS) 238 .setSmallIcon(icon) 239 .setOngoing(true); 240 241 final Intent cancelIntent = createCancelIntent(); 242 243 progressBuilder.addAction( 244 actionIcon, 245 actionTitle, 246 PendingIntent.getService( 247 service, 248 0, 249 cancelIntent, 250 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT)); 251 252 return progressBuilder; 253 } 254 255 /** 256 * Creates an intent for navigating back to the destination directory. 257 */ 258 Intent buildNavigateIntent(String tag) { 259 Intent intent = new Intent(service, FilesActivity.class); 260 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 261 intent.setAction(DocumentsContract.ACTION_BROWSE); 262 intent.setData(getDataUriForIntent(tag)); 263 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) stack); 264 return intent; 265 } 266 267 Intent createCancelIntent() { 268 final Intent cancelIntent = new Intent(service, FileOperationService.class); 269 cancelIntent.setData(getDataUriForIntent(INTENT_TAG_CANCEL)); 270 cancelIntent.putExtra(EXTRA_CANCEL, true); 271 cancelIntent.putExtra(EXTRA_JOB_ID, id); 272 return cancelIntent; 273 } 274 275 @Override 276 public String toString() { 277 return new StringBuilder() 278 .append("Job") 279 .append("{") 280 .append("id=" + id) 281 .append("}") 282 .toString(); 283 } 284 285 /** 286 * Factory class that facilitates our testing FileOperationService. 287 */ 288 static class Factory { 289 290 static final Factory instance = new Factory(); 291 292 Job createCopy(Context service, Context appContext, Listener listener, 293 String id, DocumentStack stack, List<DocumentInfo> srcs) { 294 assert(!srcs.isEmpty()); 295 assert(stack.peek().isCreateSupported()); 296 return new CopyJob(service, appContext, listener, id, stack, srcs); 297 } 298 299 Job createMove(Context service, Context appContext, Listener listener, 300 String id, DocumentStack stack, List<DocumentInfo> srcs, 301 DocumentInfo srcParent) { 302 assert(!srcs.isEmpty()); 303 assert(stack.peek().isCreateSupported()); 304 return new MoveJob(service, appContext, listener, id, stack, srcs, srcParent); 305 } 306 307 Job createDelete(Context service, Context appContext, Listener listener, 308 String id, DocumentStack stack, List<DocumentInfo> srcs, 309 DocumentInfo srcParent) { 310 assert(!srcs.isEmpty()); 311 // stack is empty if we delete docs from recent. 312 // we can't currently delete from archives. 313 assert(stack.isEmpty() || stack.peek().isDirectory()); 314 return new DeleteJob(service, appContext, listener, id, stack, srcs, srcParent); 315 } 316 } 317 318 /** 319 * Listener interface employed by the service that owns us as well as tests. 320 */ 321 interface Listener { 322 void onStart(Job job); 323 void onFinished(Job job); 324 void onProgress(CopyJob job); 325 } 326} 327