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