• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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      */
Job(Context service, Listener listener, String id, @OpType int opType, DocumentStack stack, UrisSupplier srcs, Features features)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
run()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 
setUp()174     boolean setUp() {
175         return true;
176     }
177 
finish()178     abstract void finish();
179 
start()180     abstract void start();
getSetupNotification()181     abstract Notification getSetupNotification();
getProgressNotification()182     abstract Notification getProgressNotification();
getFailureNotification()183     abstract Notification getFailureNotification();
184 
getWarningNotification()185     abstract Notification getWarningNotification();
186 
getDataUriForIntent(String tag)187     Uri getDataUriForIntent(String tag) {
188         return Uri.parse(String.format("data,%s-%s", tag, id));
189     }
190 
getClient(Uri uri)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 
getClient(DocumentInfo doc)206     ContentProviderClient getClient(DocumentInfo doc) throws RemoteException {
207         return getClient(doc.derivedUri);
208     }
209 
cleanup()210     final void cleanup() {
211         for (ContentProviderClient client : mClients.values()) {
212             ContentProviderClient.releaseQuietly(client);
213         }
214     }
215 
getState()216     final @State int getState() {
217         return mState;
218     }
219 
cancel()220     final void cancel() {
221         mState = STATE_CANCELED;
222         mSignal.cancel();
223         Metrics.logFileOperationCancelled(service, operationType);
224     }
225 
isCanceled()226     final boolean isCanceled() {
227         return mState == STATE_CANCELED;
228     }
229 
isFinished()230     final boolean isFinished() {
231         return mState == STATE_CANCELED || mState == STATE_COMPLETED;
232     }
233 
getContentResolver()234     final ContentResolver getContentResolver() {
235         return service.getContentResolver();
236     }
237 
onFileFailed(DocumentInfo file)238     void onFileFailed(DocumentInfo file) {
239         failureCount++;
240         failedDocs.add(file);
241     }
242 
onResolveFailed(Uri uri)243     void onResolveFailed(Uri uri) {
244         failureCount++;
245         failedUris.add(uri);
246     }
247 
hasFailures()248     final boolean hasFailures() {
249         return failureCount > 0;
250     }
251 
hasWarnings()252     boolean hasWarnings() {
253         return false;
254     }
255 
deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent)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 
getSetupNotification(String content)273     Notification getSetupNotification(String content) {
274         mProgressBuilder.setProgress(0, 0, true)
275                 .setContentText(content);
276         return mProgressBuilder.build();
277     }
278 
getFailureNotification(@luralsRes int titleId, @DrawableRes int icon)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 
createProgressBuilder()299     abstract Builder createProgressBuilder();
300 
createProgressBuilder( String title, @DrawableRes int icon, String actionTitle, @DrawableRes int actionIcon)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 
createNotificationBuilder()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      */
buildNavigateIntent(String tag)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 
createCancelIntent()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
toString()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 {
onStart(Job job)367         void onStart(Job job);
onFinished(Job job)368         void onFinished(Job job);
369     }
370 }
371