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