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