Home | History | Annotate | Download | only in services
      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      */
    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
    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 
    171     boolean setUp() {
    172         return true;
    173     }
    174 
    175     void finish() {
    176     }
    177 
    178     abstract void start();
    179     abstract Notification getSetupNotification();
    180     abstract Notification getProgressNotification();
    181     abstract Notification getFailureNotification();
    182 
    183     abstract Notification getWarningNotification();
    184 
    185     Uri getDataUriForIntent(String tag) {
    186         return Uri.parse(String.format("data,%s-%s", tag, id));
    187     }
    188 
    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 
    204     ContentProviderClient getClient(DocumentInfo doc) throws RemoteException {
    205         return getClient(doc.derivedUri);
    206     }
    207 
    208     final void cleanup() {
    209         for (ContentProviderClient client : mClients.values()) {
    210             ContentProviderClient.releaseQuietly(client);
    211         }
    212     }
    213 
    214     final @State int getState() {
    215         return mState;
    216     }
    217 
    218     final void cancel() {
    219         mState = STATE_CANCELED;
    220         Metrics.logFileOperationCancelled(service, operationType);
    221     }
    222 
    223     final boolean isCanceled() {
    224         return mState == STATE_CANCELED;
    225     }
    226 
    227     final boolean isFinished() {
    228         return mState == STATE_CANCELED || mState == STATE_COMPLETED;
    229     }
    230 
    231     final ContentResolver getContentResolver() {
    232         return service.getContentResolver();
    233     }
    234 
    235     void onFileFailed(DocumentInfo file) {
    236         failureCount++;
    237         failedDocs.add(file);
    238     }
    239 
    240     void onResolveFailed(Uri uri) {
    241         failureCount++;
    242         failedUris.add(uri);
    243     }
    244 
    245     final boolean hasFailures() {
    246         return failureCount > 0;
    247     }
    248 
    249     boolean hasWarnings() {
    250         return false;
    251     }
    252 
    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 
    270     Notification getSetupNotification(String content) {
    271         mProgressBuilder.setProgress(0, 0, true)
    272                 .setContentText(content);
    273         return mProgressBuilder.build();
    274     }
    275 
    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 
    296     abstract Builder createProgressBuilder();
    297 
    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 
    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      */
    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 
    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
    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 {
    364         void onStart(Job job);
    365         void onFinished(Job job);
    366     }
    367 }
    368