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_JOB_ID;
23 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
24 import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
25 import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN;
26 
27 import android.annotation.DrawableRes;
28 import android.annotation.PluralsRes;
29 import android.app.Notification;
30 import android.app.Notification.Builder;
31 import android.app.PendingIntent;
32 import android.content.ContentProviderClient;
33 import android.content.ContentResolver;
34 import android.content.Context;
35 import android.content.Intent;
36 import android.net.Uri;
37 import android.os.Parcelable;
38 import android.os.RemoteException;
39 import android.provider.DocumentsContract;
40 import android.util.Log;
41 
42 import com.android.documentsui.FilesActivity;
43 import com.android.documentsui.Metrics;
44 import com.android.documentsui.OperationDialogFragment;
45 import com.android.documentsui.R;
46 import com.android.documentsui.Shared;
47 import com.android.documentsui.model.DocumentInfo;
48 import com.android.documentsui.model.DocumentStack;
49 import com.android.documentsui.services.FileOperationService.OpType;
50 
51 import java.util.ArrayList;
52 import java.util.HashMap;
53 import java.util.List;
54 import 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  */
60 abstract 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      */
Job(Context service, Context appContext, Listener listener, @OpType int operationType, String id, DocumentStack stack)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
run()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 
start()127     abstract void start();
128 
getSetupNotification()129     abstract Notification getSetupNotification();
130     // TODO: Progress notification for deletes.
131     // abstract Notification getProgressNotification(long bytesCopied);
getFailureNotification()132     abstract Notification getFailureNotification();
133 
getWarningNotification()134     abstract Notification getWarningNotification();
135 
getDataUriForIntent(String tag)136     Uri getDataUriForIntent(String tag) {
137         return Uri.parse(String.format("data,%s-%s", tag, id));
138     }
139 
getClient(DocumentInfo doc)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 
cleanup()155     final void cleanup() {
156         for (ContentProviderClient client : mClients.values()) {
157             ContentProviderClient.releaseQuietly(client);
158         }
159     }
160 
cancel()161     final void cancel() {
162         mCanceled = true;
163         Metrics.logFileOperationCancelled(service, operationType);
164     }
165 
isCanceled()166     final boolean isCanceled() {
167         return mCanceled;
168     }
169 
getContentResolver()170     final ContentResolver getContentResolver() {
171         return service.getContentResolver();
172     }
173 
onFileFailed(DocumentInfo file)174     void onFileFailed(DocumentInfo file) {
175         failedFiles.add(file);
176     }
177 
hasFailures()178     final boolean hasFailures() {
179         return !failedFiles.isEmpty();
180     }
181 
hasWarnings()182     boolean hasWarnings() {
183         return false;
184     }
185 
deleteDocument(DocumentInfo doc, DocumentInfo parent)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 
getSetupNotification(String content)202     Notification getSetupNotification(String content) {
203         mProgressBuilder.setProgress(0, 0, true)
204                 .setContentText(content);
205         return mProgressBuilder.build();
206     }
207 
getFailureNotification(@luralsRes int titleId, @DrawableRes int icon)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 
createProgressBuilder()227     abstract Builder createProgressBuilder();
228 
createProgressBuilder( String title, @DrawableRes int icon, String actionTitle, @DrawableRes int actionIcon)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      */
buildNavigateIntent(String tag)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 
createCancelIntent()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
toString()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 
createCopy(Context service, Context appContext, Listener listener, String id, DocumentStack stack, List<DocumentInfo> srcs)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 
createMove(Context service, Context appContext, Listener listener, String id, DocumentStack stack, List<DocumentInfo> srcs, DocumentInfo srcParent)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 
createDelete(Context service, Context appContext, Listener listener, String id, DocumentStack stack, List<DocumentInfo> srcs, DocumentInfo srcParent)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 {
onStart(Job job)322         void onStart(Job job);
onFinished(Job job)323         void onFinished(Job job);
onProgress(CopyJob job)324         void onProgress(CopyJob job);
325     }
326 }
327