/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.services; import static android.content.ContentResolver.wrap; import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow; import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL; import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE; import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_DOCS; import static com.android.documentsui.services.FileOperationService.EXTRA_FAILED_URIS; import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID; import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION_TYPE; import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN; import android.app.Notification; import android.app.Notification.Builder; import android.app.PendingIntent; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.CancellationSignal; import android.os.DeadObjectException; import android.os.FileUtils; import android.os.Parcelable; import android.os.RemoteException; import android.provider.DocumentsContract; import android.util.Log; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.PluralsRes; import com.android.documentsui.Metrics; import com.android.documentsui.OperationDialogFragment; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; import com.android.documentsui.base.Features; import com.android.documentsui.base.Shared; import com.android.documentsui.clipping.UrisSupplier; import com.android.documentsui.files.FilesActivity; import com.android.documentsui.services.FileOperationService.OpType; import java.io.FileNotFoundException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import javax.annotation.Nullable; /** * A mashup of work item and ui progress update factory. Used by {@link FileOperationService} * to do work and show progress relating to this work. */ abstract public class Job implements Runnable { private static final String TAG = "Job"; @Retention(RetentionPolicy.SOURCE) @IntDef({STATE_CREATED, STATE_STARTED, STATE_SET_UP, STATE_COMPLETED, STATE_CANCELED}) @interface State {} static final int STATE_CREATED = 0; static final int STATE_STARTED = 1; static final int STATE_SET_UP = 2; static final int STATE_COMPLETED = 3; /** * A job is in canceled state as long as {@link #cancel()} is called on it, even after it is * completed. */ static final int STATE_CANCELED = 4; static final String INTENT_TAG_WARNING = "warning"; static final String INTENT_TAG_FAILURE = "failure"; static final String INTENT_TAG_PROGRESS = "progress"; static final String INTENT_TAG_CANCEL = "cancel"; final Context service; final Context appContext; final Listener listener; final @OpType int operationType; final String id; final DocumentStack stack; final UrisSupplier mResourceUris; int failureCount = 0; final ArrayList failedDocs = new ArrayList<>(); final ArrayList failedUris = new ArrayList<>(); final Notification.Builder mProgressBuilder; final CancellationSignal mSignal = new CancellationSignal(); private final Map mClients = new HashMap<>(); private final Features mFeatures; private volatile @State int mState = STATE_CREATED; /** * A simple progressable job, much like an AsyncTask, but with support * for providing various related notification, progress and navigation information. * @param service The service context in which this job is running. * @param listener * @param id Arbitrary string ID * @param stack The documents stack context relating to this request. This is the * destination in the Files app where the user will be take when the * navigation intent is invoked (presumably from notification). * @param srcs the list of docs to operate on */ Job(Context service, Listener listener, String id, @OpType int opType, DocumentStack stack, UrisSupplier srcs, Features features) { assert(opType != OPERATION_UNKNOWN); this.service = service; this.appContext = service.getApplicationContext(); this.listener = listener; this.operationType = opType; this.id = id; this.stack = stack; this.mResourceUris = srcs; mFeatures = features; mProgressBuilder = createProgressBuilder(); } @Override public final void run() { if (isCanceled()) { // Canceled before running return; } mState = STATE_STARTED; listener.onStart(this); try { boolean result = setUp(); if (result && !isCanceled()) { mState = STATE_SET_UP; start(); } } catch (RuntimeException e) { // No exceptions should be thrown here, as all calls to the provider must be // handled within Job implementations. However, just in case catch them here. Log.e(TAG, "Operation failed due to an unhandled runtime exception.", e); Metrics.logFileOperationErrors(operationType, failedDocs, failedUris); } finally { mState = (mState == STATE_STARTED || mState == STATE_SET_UP) ? STATE_COMPLETED : mState; finish(); listener.onFinished(this); // NOTE: If this details is a JumboClipDetails, and it's still referred in primary clip // at this point, user won't be able to paste it to anywhere else because the underlying mResourceUris.dispose(); } } boolean setUp() { return true; } abstract void finish(); abstract void start(); abstract Notification getSetupNotification(); abstract Notification getProgressNotification(); abstract Notification getFailureNotification(); abstract Notification getWarningNotification(); Uri getDataUriForIntent(String tag) { return Uri.parse(String.format("data,%s-%s", tag, id)); } ContentProviderClient getClient(Uri uri) throws RemoteException { ContentProviderClient client = mClients.get(uri.getAuthority()); if (client == null) { // Acquire content providers. client = acquireUnstableProviderOrThrow( getContentResolver(), uri.getAuthority()); mClients.put(uri.getAuthority(), client); } assert(client != null); return client; } ContentProviderClient getClient(DocumentInfo doc) throws RemoteException { return getClient(doc.derivedUri); } void releaseClient(Uri uri) { ContentProviderClient client = mClients.get(uri.getAuthority()); if (client != null) { client.close(); mClients.remove(uri.getAuthority()); } } void releaseClient(DocumentInfo doc) { releaseClient(doc.derivedUri); } final void cleanup() { for (ContentProviderClient client : mClients.values()) { FileUtils.closeQuietly(client); } } final @State int getState() { return mState; } final void cancel() { mState = STATE_CANCELED; mSignal.cancel(); Metrics.logFileOperationCancelled(operationType); } final boolean isCanceled() { return mState == STATE_CANCELED; } final boolean isFinished() { return mState == STATE_CANCELED || mState == STATE_COMPLETED; } final ContentResolver getContentResolver() { return service.getContentResolver(); } void onFileFailed(DocumentInfo file) { failureCount++; failedDocs.add(file); } void onResolveFailed(Uri uri) { failureCount++; failedUris.add(uri); } final boolean hasFailures() { return failureCount > 0; } boolean hasWarnings() { return false; } final void deleteDocument(DocumentInfo doc, @Nullable DocumentInfo parent) throws ResourceException { try { if (parent != null && doc.isRemoveSupported()) { DocumentsContract.removeDocument(wrap(getClient(doc)), doc.derivedUri, parent.derivedUri); } else if (doc.isDeleteSupported()) { DocumentsContract.deleteDocument(wrap(getClient(doc)), doc.derivedUri); } else { throw new ResourceException("Unable to delete source document. " + "File is not deletable or removable: %s.", doc.derivedUri); } } catch (FileNotFoundException | RemoteException | RuntimeException e) { if (e instanceof DeadObjectException) { releaseClient(doc); } throw new ResourceException("Failed to delete file %s due to an exception.", doc.derivedUri, e); } } Notification getSetupNotification(String content) { mProgressBuilder.setProgress(0, 0, true) .setContentText(content); return mProgressBuilder.build(); } Notification getFailureNotification(@PluralsRes int titleId, @DrawableRes int icon) { final Intent navigateIntent = buildNavigateIntent(INTENT_TAG_FAILURE); navigateIntent.putExtra(EXTRA_DIALOG_TYPE, OperationDialogFragment.DIALOG_TYPE_FAILURE); navigateIntent.putExtra(EXTRA_OPERATION_TYPE, operationType); navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_DOCS, failedDocs); navigateIntent.putParcelableArrayListExtra(EXTRA_FAILED_URIS, failedUris); final Notification.Builder errorBuilder = createNotificationBuilder() .setContentTitle(service.getResources().getQuantityString(titleId, failureCount, failureCount)) .setContentText(service.getString(R.string.notification_touch_for_details)) .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_MUTABLE)) .setCategory(Notification.CATEGORY_ERROR) .setSmallIcon(icon) .setAutoCancel(true); return errorBuilder.build(); } abstract Builder createProgressBuilder(); final Builder createProgressBuilder( String title, @DrawableRes int icon, String actionTitle, @DrawableRes int actionIcon) { Notification.Builder progressBuilder = createNotificationBuilder() .setContentTitle(title) .setContentIntent( PendingIntent.getActivity(appContext, 0, buildNavigateIntent(INTENT_TAG_PROGRESS), PendingIntent.FLAG_IMMUTABLE)) .setCategory(Notification.CATEGORY_PROGRESS) .setSmallIcon(icon) .setOngoing(true); final Intent cancelIntent = createCancelIntent(); progressBuilder.addAction( actionIcon, actionTitle, PendingIntent.getService( service, 0, cancelIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE)); return progressBuilder; } Notification.Builder createNotificationBuilder() { return mFeatures.isNotificationChannelEnabled() ? new Notification.Builder(service, FileOperationService.NOTIFICATION_CHANNEL_ID) : new Notification.Builder(service); } /** * Creates an intent for navigating back to the destination directory. */ Intent buildNavigateIntent(String tag) { // TODO (b/35721285): Reuse an existing task rather than creating a new one every time. Intent intent = new Intent(service, FilesActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setData(getDataUriForIntent(tag)); intent.putExtra(Shared.EXTRA_STACK, (Parcelable) stack); return intent; } Intent createCancelIntent() { final Intent cancelIntent = new Intent(service, FileOperationService.class); cancelIntent.setData(getDataUriForIntent(INTENT_TAG_CANCEL)); cancelIntent.putExtra(EXTRA_CANCEL, true); cancelIntent.putExtra(EXTRA_JOB_ID, id); return cancelIntent; } @Override public String toString() { return new StringBuilder() .append("Job") .append("{") .append("id=" + id) .append("}") .toString(); } /** * Listener interface employed by the service that owns us as well as tests. */ interface Listener { void onStart(Job job); void onFinished(Job job); } /** * Interface for tracking job progress. */ interface ProgressTracker { default double getProgress() { return -1; } default long getRemainingTimeEstimate() { return -1; } } }