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_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      */
     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