blob: 71f0ae86152d776593f94f7df5e77b8901209eb4 [file] [log] [blame]
/*
* 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<DocumentInfo> failedDocs = new ArrayList<>();
final ArrayList<Uri> failedUris = new ArrayList<>();
final Notification.Builder mProgressBuilder;
final CancellationSignal mSignal = new CancellationSignal();
private final Map<String, ContentProviderClient> 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;
}
}
}