blob: 4ea7bbc2de7484152b282688b410e779fe5d436b [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.picker;
import static android.provider.DocumentsContract.isDocumentUri;
import static android.provider.DocumentsContract.isRootUri;
import static com.android.documentsui.base.SharedMinimal.DEBUG;
import static com.android.documentsui.base.State.ACTION_CREATE;
import static com.android.documentsui.base.State.ACTION_GET_CONTENT;
import static com.android.documentsui.base.State.ACTION_OPEN;
import static com.android.documentsui.base.State.ACTION_OPEN_TREE;
import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Parcelable;
import android.provider.DocumentsContract;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import com.android.documentsui.AbstractActionHandler;
import com.android.documentsui.ActivityConfig;
import com.android.documentsui.DocumentsAccess;
import com.android.documentsui.Injector;
import com.android.documentsui.MetricConsts;
import com.android.documentsui.Metrics;
import com.android.documentsui.base.BooleanConsumer;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.DocumentStack;
import com.android.documentsui.base.Features;
import com.android.documentsui.base.Lookup;
import com.android.documentsui.base.Providers;
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.base.Shared;
import com.android.documentsui.base.State;
import com.android.documentsui.base.UserId;
import com.android.documentsui.dirlist.AnimationView;
import com.android.documentsui.picker.ActionHandler.Addons;
import com.android.documentsui.queries.SearchViewManager;
import com.android.documentsui.roots.ProvidersAccess;
import com.android.documentsui.services.FileOperationService;
import com.android.documentsui.util.FileUtils;
import java.io.IOException;
import java.util.Arrays;
import java.util.concurrent.Executor;
import java.util.regex.Pattern;
/**
* Provides {@link PickActivity} action specializations to fragments.
*/
class ActionHandler<T extends FragmentActivity & Addons> extends AbstractActionHandler<T> {
private static final String TAG = "PickerActionHandler";
/**
* Used to prevent applications from using {@link Intent#ACTION_OPEN_DOCUMENT_TREE} and
* the {@link Intent#ACTION_OPEN_DOCUMENT} actions to request that the user select individual
* files from "/Android/data", "/Android/obb", "/Android/sandbox" directories and all their
* subdirectories (on the external storage), in accordance with the SAF privacy restrictions
* introduced in Android 11 (R).
*
* <p>
* See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
* Storage updates in Android 11</a>.
*/
private static final Pattern PATTERN_RESTRICTED_INITIAL_PATH =
Pattern.compile("^/Android/(?:data|obb|sandbox).*", CASE_INSENSITIVE);
private final Features mFeatures;
private final ActivityConfig mConfig;
private final LastAccessedStorage mLastAccessed;
private UpdatePickResultTask mUpdatePickResultTask;
ActionHandler(
T activity,
State state,
ProvidersAccess providers,
DocumentsAccess docs,
SearchViewManager searchMgr,
Lookup<String, Executor> executors,
Injector injector,
LastAccessedStorage lastAccessed) {
super(activity, state, providers, docs, searchMgr, executors, injector);
mConfig = injector.config;
mFeatures = injector.features;
mLastAccessed = lastAccessed;
mUpdatePickResultTask = new UpdatePickResultTask(
activity.getApplicationContext(), mInjector.pickResult);
}
@Override
public void initLocation(Intent intent) {
assert (intent != null);
// stack is initialized if it's restored from bundle, which means we're restoring a
// previously stored state.
if (mState.stack.isInitialized()) {
if (DEBUG) {
Log.d(TAG, "Stack already resolved for uri: " + intent.getData());
}
restoreRootAndDirectory();
return;
}
if (launchHomeForCopyDestination(intent)) {
if (DEBUG) {
Log.d(TAG, "Launching directly into Home directory for copy destination.");
}
return;
}
if (mFeatures.isLaunchToDocumentEnabled() && launchToInitialUri(intent)) {
if (DEBUG) {
Log.d(TAG, "Launched to initial uri.");
}
return;
}
if (DEBUG) {
Log.d(TAG, "Load last accessed stack.");
}
initLoadLastAccessedStack();
}
@Override
protected void launchToDefaultLocation() {
loadLastAccessedStack();
}
private boolean launchHomeForCopyDestination(Intent intent) {
// As a matter of policy we don't load the last used stack for the copy
// destination picker (user is already in Files app).
// Consensus was that the experice was too confusing.
// In all other cases, where the user is visiting us from another app
// we restore the stack as last used from that app.
if (Shared.ACTION_PICK_COPY_DESTINATION.equals(intent.getAction())) {
loadHomeDir();
return true;
}
return false;
}
private boolean launchToInitialUri(Intent intent) {
final Uri initialUri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI);
if (initialUri == null) {
return false;
}
final boolean isRoot = isRootUri(mActivity, initialUri);
final boolean isDocument = !isRoot && isDocumentUri(mActivity, initialUri);
if (!isRoot && !isDocument) {
// Neither a root nor a document.
return false;
}
if (isRoot) {
loadRoot(initialUri, UserId.DEFAULT_USER);
return true;
}
// From here onwards: isDoc == true.
if (shouldPreemptivelyRestrictRequestedInitialUri(initialUri)) {
Log.w(TAG, "Requested initial URI - " + initialUri + " - is restricted: "
+ "loading device root instead.");
return false;
}
return launchToDocument(initialUri);
}
/**
* Starting with Android 11 (R, API Level 30) applications are no longer allowed to use the
* {@link Intent#ACTION_OPEN_DOCUMENT} and {@link Intent#ACTION_OPEN_DOCUMENT_TREE} to request
* that the user select individual files from "Android/data/", "Android/obb/",
* "Android/sandbox/" directories and all their subdirectories on "external storage".
* <p>
* See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
* Storage updates in Android 11</a>.
* <p>
* Ideally, this should be handled on the {@code ExternalStorageProvider} side, but as of
* Android 14 (U) FRC, {@code ExternalStorageProvider} "hides" only "Android/data/",
* "Android/obb/" and "Android/sandbox/" directories, but NOT their subdirectories.
*/
private boolean shouldPreemptivelyRestrictRequestedInitialUri(@NonNull Uri uri) {
// Not restricting SAF access for the calling app.
if (!Shared.shouldRestrictStorageAccessFramework(mActivity)) {
return false;
}
// We only need to restrict some locations on the "external" storage.
if (!Providers.AUTHORITY_STORAGE.equals(uri.getAuthority())) {
return false;
}
// TODO(b/283962634): in the future this will have to be platform-version specific.
// For example, if the fix on the ExternalStorageProvider side makes it to the Android 15,
// we would change this to check if the platform version >= 15.
// In the upcoming Android 14 release, however, ExternalStorageProvider does NOT yet
// implement this logic.
final boolean externalProviderImplementsSafRestrictions = false;
if (externalProviderImplementsSafRestrictions) {
return false;
}
// External Storage Provider's docId format is "root:path/to/file"
// The getPathFromStorageDocId() turns that into "/path/to/file"
// Note the missing leading "/" in the path part of the docId, while the path returned by
// the getPathFromStorageDocId() start with "/".
final String docId = DocumentsContract.getDocumentId(uri);
final String filePath;
try {
filePath = FileUtils.getPathFromStorageDocId(docId);
} catch (IOException e) {
Log.w(TAG, "Could not get canonical file path from docId '" + docId + "'");
return true;
}
// Check if the app is asking for /Android/data, /Android/obb, /Android/sandbox or any of
// their subdirectories (on the external storage).
return PATTERN_RESTRICTED_INITIAL_PATH.matcher(filePath).matches();
}
private void initLoadLastAccessedStack() {
if (DEBUG) {
Log.d(TAG, "Attempting to load last used stack for calling package.");
}
// Block UI until stack is fully loaded, else there is an intermediate incomplete UI state.
onLastAccessedStackLoaded(mLastAccessed.getLastAccessed(mActivity, mProviders, mState));
}
private void loadLastAccessedStack() {
if (DEBUG) {
Log.d(TAG, "Attempting to load last used stack for calling package.");
}
new LoadLastAccessedStackTask<>(
mActivity, mLastAccessed, mState, mProviders, this::onLastAccessedStackLoaded)
.execute();
}
private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) {
if (stack == null) {
loadDefaultLocation();
} else {
mState.stack.reset(stack);
mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
}
}
public UpdatePickResultTask getUpdatePickResultTask() {
return mUpdatePickResultTask;
}
private void updatePickResult(Intent intent, boolean isSearching, int root) {
ClipData cdata = intent.getClipData();
int fileCount = 0;
Uri uri = null;
// There are 2 cases that would be single-select:
// 1. getData() isn't null and getClipData() is null.
// 2. getClipData() isn't null and the item count of it is 1.
if (intent.getData() != null && cdata == null) {
fileCount = 1;
uri = intent.getData();
} else if (cdata != null) {
fileCount = cdata.getItemCount();
if (fileCount == 1) {
uri = cdata.getItemAt(0).getUri();
}
}
mInjector.pickResult.setFileCount(fileCount);
mInjector.pickResult.setIsSearching(isSearching);
mInjector.pickResult.setRoot(root);
mInjector.pickResult.setFileUri(uri);
getUpdatePickResultTask().safeExecute();
}
private void loadDefaultLocation() {
switch (mState.action) {
case ACTION_CREATE:
loadHomeDir();
break;
case ACTION_OPEN_TREE:
loadDeviceRoot();
break;
case ACTION_GET_CONTENT:
case ACTION_OPEN:
loadRecent();
break;
default:
throw new UnsupportedOperationException("Unexpected action type: " + mState.action);
}
}
@Override
public void showAppDetails(ResolveInfo info, UserId userId) {
mInjector.pickResult.increaseActionCount();
final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", info.activityInfo.packageName, null));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
userId.startActivityAsUser(mActivity, intent);
}
@Override
public void openInNewWindow(DocumentStack path) {
// Open new window support only depends on vanilla Activity, so it is
// implemented in our parent class. But we don't support that in
// picking. So as a matter of defensiveness, we override that here.
throw new UnsupportedOperationException("Can't open in new window");
}
@Override
public void openRoot(RootInfo root) {
Metrics.logRootVisited(MetricConsts.PICKER_SCOPE, root);
mInjector.pickResult.increaseActionCount();
mActivity.onRootPicked(root);
}
@Override
public void openRoot(ResolveInfo info, UserId userId) {
Metrics.logAppVisited(info);
mInjector.pickResult.increaseActionCount();
// The App root item should not show if we cannot interact with the target user.
// But the user managed to get here, this is the final check of permission. We don't
// perform the check on activity result.
if (!mState.canInteractWith(userId)) {
mInjector.dialogs.showActionNotAllowed();
return;
}
Intent intent = new Intent(mActivity.getIntent());
final int flagsRemoved = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
intent.setFlags(intent.getFlags() & ~flagsRemoved);
intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
intent.setComponent(new ComponentName(
info.activityInfo.applicationInfo.packageName, info.activityInfo.name));
try {
boolean isCurrentUser = UserId.CURRENT_USER.equals(userId);
if (isCurrentUser) {
mActivity.startActivity(intent);
} else {
userId.startActivityAsUser(mActivity, intent);
}
Metrics.logLaunchOtherApp(!UserId.CURRENT_USER.equals(userId));
mActivity.finish();
} catch (SecurityException | ActivityNotFoundException e) {
Log.e(TAG, "Caught error: " + e.getLocalizedMessage());
mInjector.dialogs.showNoApplicationFound();
}
}
@Override
public void springOpenDirectory(DocumentInfo doc) {
}
@Override
public boolean openItem(ItemDetails<String> details, @ViewType int type,
@ViewType int fallback) {
mInjector.pickResult.increaseActionCount();
DocumentInfo doc = mModel.getDocument(details.getSelectionKey());
if (doc == null) {
Log.w(TAG, "Can't view item. No Document available for modeId: "
+ details.getSelectionKey());
return false;
}
if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) {
mActivity.onDocumentPicked(doc);
mSelectionMgr.clearSelection();
return !doc.isDirectory();
}
return false;
}
@Override
public boolean previewItem(ItemDetails<String> details) {
mInjector.pickResult.increaseActionCount();
final DocumentInfo doc = mModel.getDocument(details.getSelectionKey());
if (doc == null) {
Log.w(TAG, "Can't view item. No Document available for modeId: "
+ details.getSelectionKey());
return false;
}
onDocumentOpened(doc, VIEW_TYPE_PREVIEW, VIEW_TYPE_REGULAR, true);
return !doc.isContainer();
}
void pickDocument(FragmentManager fm, DocumentInfo pickTarget) {
assert (pickTarget != null);
mInjector.pickResult.increaseActionCount();
Uri result;
switch (mState.action) {
case ACTION_OPEN_TREE:
mInjector.dialogs.confirmAction(fm, pickTarget, ConfirmFragment.TYPE_OEPN_TREE);
break;
case ACTION_PICK_COPY_DESTINATION:
result = pickTarget.derivedUri;
finishPicking(result);
break;
default:
// Should not be reached
throw new IllegalStateException("Invalid mState.action");
}
}
void saveDocument(
String mimeType, String displayName, BooleanConsumer inProgressStateListener) {
assert (mState.action == ACTION_CREATE);
mInjector.pickResult.increaseActionCount();
new CreatePickedDocumentTask(
mActivity,
mDocs,
mLastAccessed,
mState.stack,
mimeType,
displayName,
inProgressStateListener,
this::onPickFinished)
.executeOnExecutor(getExecutorForCurrentDirectory());
}
// User requested to overwrite a target. If confirmed by user #finishPicking() will be
// called.
void saveDocument(FragmentManager fm, DocumentInfo replaceTarget) {
assert (mState.action == ACTION_CREATE);
mInjector.pickResult.increaseActionCount();
assert (replaceTarget != null);
// Adding a confirmation dialog breaks an inherited CTS test (testCreateExisting), so we
// need to add a feature flag to bypass this feature in ARC++ environment.
if (mFeatures.isOverwriteConfirmationEnabled()) {
mInjector.dialogs.confirmAction(fm, replaceTarget, ConfirmFragment.TYPE_OVERWRITE);
} else {
finishPicking(replaceTarget.getDocumentUri());
}
}
void finishPicking(Uri... docs) {
new SetLastAccessedStackTask(
mActivity,
mLastAccessed,
mState.stack,
() -> {
onPickFinished(docs);
}
).executeOnExecutor(getExecutorForCurrentDirectory());
}
private void onPickFinished(Uri... uris) {
if (DEBUG) {
Log.d(TAG, "onFinished() " + Arrays.toString(uris));
}
final Intent intent = new Intent();
if (uris.length == 1) {
intent.setData(uris[0]);
} else if (uris.length > 1) {
final ClipData clipData = new ClipData(
null, mState.acceptMimes, new ClipData.Item(uris[0]));
for (int i = 1; i < uris.length; i++) {
clipData.addItem(new ClipData.Item(uris[i]));
}
intent.setClipData(clipData);
}
updatePickResult(
intent, mSearchMgr.isSearching(), Metrics.sanitizeRoot(mState.stack.getRoot()));
// TODO: Separate this piece of logic per action.
// We don't instantiate different objects for different actions at the first place, so it's
// not a easy task to separate this logic cleanly.
// Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its
// inheritance structure.
if (mState.action == ACTION_GET_CONTENT) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else if (mState.action == ACTION_OPEN_TREE) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
} else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
// Picking a copy destination is only used internally by us, so we
// don't need to extend permissions to the caller.
intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack);
intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType);
} else {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
}
mActivity.setResult(FragmentActivity.RESULT_OK, intent, 0);
mActivity.finish();
}
private Executor getExecutorForCurrentDirectory() {
final DocumentInfo cwd = mState.stack.peek();
if (cwd != null && cwd.authority != null) {
return mExecutors.lookup(cwd.authority);
} else {
return AsyncTask.THREAD_POOL_EXECUTOR;
}
}
public interface Addons extends CommonAddons {
@Override
void onDocumentPicked(DocumentInfo doc);
/**
* Overload final method {@link FragmentActivity#setResult(int, Intent)} so that we can
* intercept this method call in test environment.
*/
@VisibleForTesting
void setResult(int resultCode, Intent result, int notUsed);
}
}