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