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 package com.android.documentsui.picker;
17 
18 import static android.provider.DocumentsContract.isDocumentUri;
19 import static android.provider.DocumentsContract.isRootUri;
20 
21 import static com.android.documentsui.base.SharedMinimal.DEBUG;
22 import static com.android.documentsui.base.State.ACTION_CREATE;
23 import static com.android.documentsui.base.State.ACTION_GET_CONTENT;
24 import static com.android.documentsui.base.State.ACTION_OPEN;
25 import static com.android.documentsui.base.State.ACTION_OPEN_TREE;
26 import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION;
27 
28 import static java.util.regex.Pattern.CASE_INSENSITIVE;
29 
30 import android.content.ActivityNotFoundException;
31 import android.content.ClipData;
32 import android.content.ComponentName;
33 import android.content.Intent;
34 import android.content.pm.ResolveInfo;
35 import android.net.Uri;
36 import android.os.AsyncTask;
37 import android.os.Parcelable;
38 import android.provider.DocumentsContract;
39 import android.provider.Settings;
40 import android.util.Log;
41 
42 import androidx.annotation.NonNull;
43 import androidx.annotation.Nullable;
44 import androidx.annotation.VisibleForTesting;
45 import androidx.fragment.app.FragmentActivity;
46 import androidx.fragment.app.FragmentManager;
47 import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
48 
49 import com.android.documentsui.AbstractActionHandler;
50 import com.android.documentsui.ActivityConfig;
51 import com.android.documentsui.DocumentsAccess;
52 import com.android.documentsui.Injector;
53 import com.android.documentsui.MetricConsts;
54 import com.android.documentsui.Metrics;
55 import com.android.documentsui.base.BooleanConsumer;
56 import com.android.documentsui.base.DocumentInfo;
57 import com.android.documentsui.base.DocumentStack;
58 import com.android.documentsui.base.Features;
59 import com.android.documentsui.base.Lookup;
60 import com.android.documentsui.base.Providers;
61 import com.android.documentsui.base.RootInfo;
62 import com.android.documentsui.base.Shared;
63 import com.android.documentsui.base.State;
64 import com.android.documentsui.base.UserId;
65 import com.android.documentsui.dirlist.AnimationView;
66 import com.android.documentsui.picker.ActionHandler.Addons;
67 import com.android.documentsui.queries.SearchViewManager;
68 import com.android.documentsui.roots.ProvidersAccess;
69 import com.android.documentsui.services.FileOperationService;
70 import com.android.documentsui.util.FileUtils;
71 
72 import java.io.IOException;
73 import java.util.Arrays;
74 import java.util.concurrent.Executor;
75 import java.util.regex.Pattern;
76 
77 /**
78  * Provides {@link PickActivity} action specializations to fragments.
79  */
80 class ActionHandler<T extends FragmentActivity & Addons> extends AbstractActionHandler<T> {
81 
82     private static final String TAG = "PickerActionHandler";
83 
84     /**
85      * Used to prevent applications from using {@link Intent#ACTION_OPEN_DOCUMENT_TREE} and
86      * the {@link Intent#ACTION_OPEN_DOCUMENT} actions to request that the user select individual
87      * files from "/Android/data", "/Android/obb", "/Android/sandbox" directories and all their
88      * subdirectories (on the external storage), in accordance with the SAF privacy restrictions
89      * introduced in Android 11 (R).
90      *
91      * <p>
92      * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
93      * Storage updates in Android 11</a>.
94      */
95     private static final Pattern PATTERN_RESTRICTED_INITIAL_PATH =
96             Pattern.compile("^/Android/(?:data|obb|sandbox).*", CASE_INSENSITIVE);
97 
98     private final Features mFeatures;
99     private final ActivityConfig mConfig;
100     private final LastAccessedStorage mLastAccessed;
101     private UpdatePickResultTask mUpdatePickResultTask;
102 
ActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, Injector injector, LastAccessedStorage lastAccessed)103     ActionHandler(
104             T activity,
105             State state,
106             ProvidersAccess providers,
107             DocumentsAccess docs,
108             SearchViewManager searchMgr,
109             Lookup<String, Executor> executors,
110             Injector injector,
111             LastAccessedStorage lastAccessed) {
112         super(activity, state, providers, docs, searchMgr, executors, injector);
113 
114         mConfig = injector.config;
115         mFeatures = injector.features;
116         mLastAccessed = lastAccessed;
117         mUpdatePickResultTask = new UpdatePickResultTask(
118                 activity.getApplicationContext(), mInjector.pickResult);
119     }
120 
121     @Override
initLocation(Intent intent)122     public void initLocation(Intent intent) {
123         assert (intent != null);
124 
125         // stack is initialized if it's restored from bundle, which means we're restoring a
126         // previously stored state.
127         if (mState.stack.isInitialized()) {
128             if (DEBUG) {
129                 Log.d(TAG, "Stack already resolved for uri: " + intent.getData());
130             }
131             restoreRootAndDirectory();
132             return;
133         }
134 
135         if (launchHomeForCopyDestination(intent)) {
136             if (DEBUG) {
137                 Log.d(TAG, "Launching directly into Home directory for copy destination.");
138             }
139             return;
140         }
141 
142         if (mFeatures.isLaunchToDocumentEnabled() && launchToInitialUri(intent)) {
143             if (DEBUG) {
144                 Log.d(TAG, "Launched to initial uri.");
145             }
146             return;
147         }
148 
149         if (DEBUG) {
150             Log.d(TAG, "Load last accessed stack.");
151         }
152         initLoadLastAccessedStack();
153     }
154 
155     @Override
launchToDefaultLocation()156     protected void launchToDefaultLocation() {
157         loadLastAccessedStack();
158     }
159 
launchHomeForCopyDestination(Intent intent)160     private boolean launchHomeForCopyDestination(Intent intent) {
161         // As a matter of policy we don't load the last used stack for the copy
162         // destination picker (user is already in Files app).
163         // Consensus was that the experice was too confusing.
164         // In all other cases, where the user is visiting us from another app
165         // we restore the stack as last used from that app.
166         if (Shared.ACTION_PICK_COPY_DESTINATION.equals(intent.getAction())) {
167             loadHomeDir();
168             return true;
169         }
170 
171         return false;
172     }
173 
launchToInitialUri(Intent intent)174     private boolean launchToInitialUri(Intent intent) {
175         final Uri initialUri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI);
176         if (initialUri == null) {
177             return false;
178         }
179 
180         final boolean isRoot = isRootUri(mActivity, initialUri);
181         final boolean isDocument = !isRoot && isDocumentUri(mActivity, initialUri);
182 
183         if (!isRoot && !isDocument) {
184             // Neither a root nor a document.
185             return false;
186         }
187 
188         if (isRoot) {
189             loadRoot(initialUri, UserId.DEFAULT_USER);
190             return true;
191         }
192         // From here onwards: isDoc == true.
193 
194         if (shouldPreemptivelyRestrictRequestedInitialUri(initialUri)) {
195             Log.w(TAG, "Requested initial URI - " + initialUri + " - is restricted: "
196                     + "loading device root instead.");
197             return false;
198         }
199 
200         return launchToDocument(initialUri);
201     }
202 
203     /**
204      * Starting with Android 11 (R, API Level 30) applications are no longer allowed to use the
205      * {@link Intent#ACTION_OPEN_DOCUMENT} and {@link Intent#ACTION_OPEN_DOCUMENT_TREE} to request
206      * that the user select individual files from "Android/data/", "Android/obb/",
207      * "Android/sandbox/" directories and all their subdirectories on "external storage".
208      * <p>
209      * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
210      * Storage updates in Android 11</a>.
211      * <p>
212      * Ideally, this should be handled on the {@code ExternalStorageProvider} side, but as of
213      * Android 14 (U) FRC, {@code ExternalStorageProvider} "hides" only "Android/data/",
214      * "Android/obb/" and "Android/sandbox/" directories, but NOT their subdirectories.
215      */
shouldPreemptivelyRestrictRequestedInitialUri(@onNull Uri uri)216     private boolean shouldPreemptivelyRestrictRequestedInitialUri(@NonNull Uri uri) {
217         // Not restricting SAF access for the calling app.
218         if (!Shared.shouldRestrictStorageAccessFramework(mActivity)) {
219             return false;
220         }
221 
222         // We only need to restrict some locations on the "external" storage.
223         if (!Providers.AUTHORITY_STORAGE.equals(uri.getAuthority())) {
224             return false;
225         }
226 
227         // TODO(b/283962634): in the future this will have to be platform-version specific.
228         //  For example, if the fix on the ExternalStorageProvider side makes it to the Android 15,
229         //  we would change this to check if the platform version >= 15.
230         //  In the upcoming Android 14 release, however, ExternalStorageProvider does NOT yet
231         //  implement this logic.
232         final boolean externalProviderImplementsSafRestrictions = false;
233         if (externalProviderImplementsSafRestrictions) {
234             return false;
235         }
236 
237         // External Storage Provider's docId format is "root:path/to/file"
238         // The getPathFromStorageDocId() turns that into "/path/to/file"
239         // Note the missing leading "/" in the path part of the docId, while the path returned by
240         // the getPathFromStorageDocId() start with "/".
241         final String docId = DocumentsContract.getDocumentId(uri);
242         final String filePath;
243         try {
244             filePath = FileUtils.getPathFromStorageDocId(docId);
245         } catch (IOException e) {
246             Log.w(TAG, "Could not get canonical file path from docId '" + docId + "'");
247             return true;
248         }
249 
250         // Check if the app is asking for /Android/data, /Android/obb, /Android/sandbox or any of
251         // their subdirectories (on the external storage).
252         return PATTERN_RESTRICTED_INITIAL_PATH.matcher(filePath).matches();
253     }
254 
initLoadLastAccessedStack()255     private void initLoadLastAccessedStack() {
256         if (DEBUG) {
257             Log.d(TAG, "Attempting to load last used stack for calling package.");
258         }
259         // Block UI until stack is fully loaded, else there is an intermediate incomplete UI state.
260         onLastAccessedStackLoaded(mLastAccessed.getLastAccessed(mActivity, mProviders, mState));
261     }
262 
loadLastAccessedStack()263     private void loadLastAccessedStack() {
264         if (DEBUG) {
265             Log.d(TAG, "Attempting to load last used stack for calling package.");
266         }
267         new LoadLastAccessedStackTask<>(
268                 mActivity, mLastAccessed, mState, mProviders, this::onLastAccessedStackLoaded)
269                 .execute();
270     }
271 
onLastAccessedStackLoaded(@ullable DocumentStack stack)272     private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) {
273         if (stack == null) {
274             loadDefaultLocation();
275         } else {
276             mState.stack.reset(stack);
277             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
278         }
279     }
280 
getUpdatePickResultTask()281     public UpdatePickResultTask getUpdatePickResultTask() {
282         return mUpdatePickResultTask;
283     }
284 
updatePickResult(Intent intent, boolean isSearching, int root)285     private void updatePickResult(Intent intent, boolean isSearching, int root) {
286         ClipData cdata = intent.getClipData();
287         int fileCount = 0;
288         Uri uri = null;
289 
290         // There are 2 cases that would be single-select:
291         // 1. getData() isn't null and getClipData() is null.
292         // 2. getClipData() isn't null and the item count of it is 1.
293         if (intent.getData() != null && cdata == null) {
294             fileCount = 1;
295             uri = intent.getData();
296         } else if (cdata != null) {
297             fileCount = cdata.getItemCount();
298             if (fileCount == 1) {
299                 uri = cdata.getItemAt(0).getUri();
300             }
301         }
302 
303         mInjector.pickResult.setFileCount(fileCount);
304         mInjector.pickResult.setIsSearching(isSearching);
305         mInjector.pickResult.setRoot(root);
306         mInjector.pickResult.setFileUri(uri);
307         getUpdatePickResultTask().safeExecute();
308     }
309 
loadDefaultLocation()310     private void loadDefaultLocation() {
311         switch (mState.action) {
312             case ACTION_CREATE:
313                 loadHomeDir();
314                 break;
315             case ACTION_OPEN_TREE:
316                 loadDeviceRoot();
317                 break;
318             case ACTION_GET_CONTENT:
319             case ACTION_OPEN:
320                 loadRecent();
321                 break;
322             default:
323                 throw new UnsupportedOperationException("Unexpected action type: " + mState.action);
324         }
325     }
326 
327     @Override
showAppDetails(ResolveInfo info, UserId userId)328     public void showAppDetails(ResolveInfo info, UserId userId) {
329         mInjector.pickResult.increaseActionCount();
330         final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
331         intent.setData(Uri.fromParts("package", info.activityInfo.packageName, null));
332         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
333         userId.startActivityAsUser(mActivity, intent);
334     }
335 
336     @Override
openInNewWindow(DocumentStack path)337     public void openInNewWindow(DocumentStack path) {
338         // Open new window support only depends on vanilla Activity, so it is
339         // implemented in our parent class. But we don't support that in
340         // picking. So as a matter of defensiveness, we override that here.
341         throw new UnsupportedOperationException("Can't open in new window");
342     }
343 
344     @Override
openRoot(RootInfo root)345     public void openRoot(RootInfo root) {
346         Metrics.logRootVisited(MetricConsts.PICKER_SCOPE, root);
347         mInjector.pickResult.increaseActionCount();
348         mActivity.onRootPicked(root);
349     }
350 
351     @Override
openRoot(ResolveInfo info, UserId userId)352     public void openRoot(ResolveInfo info, UserId userId) {
353         Metrics.logAppVisited(info);
354         mInjector.pickResult.increaseActionCount();
355 
356         // The App root item should not show if we cannot interact with the target user.
357         // But the user managed to get here, this is the final check of permission. We don't
358         // perform the check on activity result.
359         if (!mState.canInteractWith(userId)) {
360             mInjector.dialogs.showActionNotAllowed();
361             return;
362         }
363 
364         Intent intent = new Intent(mActivity.getIntent());
365         final int flagsRemoved = Intent.FLAG_GRANT_READ_URI_PERMISSION
366                 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
367                 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
368                 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
369         intent.setFlags(intent.getFlags() & ~flagsRemoved);
370         intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
371         intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
372         intent.setComponent(new ComponentName(
373                 info.activityInfo.applicationInfo.packageName, info.activityInfo.name));
374         try {
375             boolean isCurrentUser = UserId.CURRENT_USER.equals(userId);
376             if (isCurrentUser) {
377                 mActivity.startActivity(intent);
378             } else {
379                 userId.startActivityAsUser(mActivity, intent);
380             }
381             Metrics.logLaunchOtherApp(!UserId.CURRENT_USER.equals(userId));
382             mActivity.finish();
383         } catch (SecurityException | ActivityNotFoundException e) {
384             Log.e(TAG, "Caught error: " + e.getLocalizedMessage());
385             mInjector.dialogs.showNoApplicationFound();
386         }
387     }
388 
389 
390     @Override
springOpenDirectory(DocumentInfo doc)391     public void springOpenDirectory(DocumentInfo doc) {
392     }
393 
394     @Override
openItem(ItemDetails<String> details, @ViewType int type, @ViewType int fallback)395     public boolean openItem(ItemDetails<String> details, @ViewType int type,
396             @ViewType int fallback) {
397         mInjector.pickResult.increaseActionCount();
398         DocumentInfo doc = mModel.getDocument(details.getSelectionKey());
399         if (doc == null) {
400             Log.w(TAG, "Can't view item. No Document available for modeId: "
401                     + details.getSelectionKey());
402             return false;
403         }
404 
405         if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) {
406             mActivity.onDocumentPicked(doc);
407             mSelectionMgr.clearSelection();
408             return !doc.isDirectory();
409         }
410         return false;
411     }
412 
413     @Override
previewItem(ItemDetails<String> details)414     public boolean previewItem(ItemDetails<String> details) {
415         mInjector.pickResult.increaseActionCount();
416         final DocumentInfo doc = mModel.getDocument(details.getSelectionKey());
417         if (doc == null) {
418             Log.w(TAG, "Can't view item. No Document available for modeId: "
419                     + details.getSelectionKey());
420             return false;
421         }
422 
423         onDocumentOpened(doc, VIEW_TYPE_PREVIEW, VIEW_TYPE_REGULAR, true);
424         return !doc.isContainer();
425     }
426 
pickDocument(FragmentManager fm, DocumentInfo pickTarget)427     void pickDocument(FragmentManager fm, DocumentInfo pickTarget) {
428         assert (pickTarget != null);
429         mInjector.pickResult.increaseActionCount();
430         Uri result;
431         switch (mState.action) {
432             case ACTION_OPEN_TREE:
433                 mInjector.dialogs.confirmAction(fm, pickTarget, ConfirmFragment.TYPE_OEPN_TREE);
434                 break;
435             case ACTION_PICK_COPY_DESTINATION:
436                 result = pickTarget.derivedUri;
437                 finishPicking(result);
438                 break;
439             default:
440                 // Should not be reached
441                 throw new IllegalStateException("Invalid mState.action");
442         }
443     }
444 
saveDocument( String mimeType, String displayName, BooleanConsumer inProgressStateListener)445     void saveDocument(
446             String mimeType, String displayName, BooleanConsumer inProgressStateListener) {
447         assert (mState.action == ACTION_CREATE);
448         mInjector.pickResult.increaseActionCount();
449         new CreatePickedDocumentTask(
450                 mActivity,
451                 mDocs,
452                 mLastAccessed,
453                 mState.stack,
454                 mimeType,
455                 displayName,
456                 inProgressStateListener,
457                 this::onPickFinished)
458                 .executeOnExecutor(getExecutorForCurrentDirectory());
459     }
460 
461     // User requested to overwrite a target. If confirmed by user #finishPicking() will be
462     // called.
saveDocument(FragmentManager fm, DocumentInfo replaceTarget)463     void saveDocument(FragmentManager fm, DocumentInfo replaceTarget) {
464         assert (mState.action == ACTION_CREATE);
465         mInjector.pickResult.increaseActionCount();
466         assert (replaceTarget != null);
467 
468         // Adding a confirmation dialog breaks an inherited CTS test (testCreateExisting), so we
469         // need to add a feature flag to bypass this feature in ARC++ environment.
470         if (mFeatures.isOverwriteConfirmationEnabled()) {
471             mInjector.dialogs.confirmAction(fm, replaceTarget, ConfirmFragment.TYPE_OVERWRITE);
472         } else {
473             finishPicking(replaceTarget.getDocumentUri());
474         }
475     }
476 
finishPicking(Uri... docs)477     void finishPicking(Uri... docs) {
478         new SetLastAccessedStackTask(
479                 mActivity,
480                 mLastAccessed,
481                 mState.stack,
482                 () -> {
483                     onPickFinished(docs);
484                 }
485         ).executeOnExecutor(getExecutorForCurrentDirectory());
486     }
487 
onPickFinished(Uri... uris)488     private void onPickFinished(Uri... uris) {
489         if (DEBUG) {
490             Log.d(TAG, "onFinished() " + Arrays.toString(uris));
491         }
492 
493         final Intent intent = new Intent();
494         if (uris.length == 1) {
495             intent.setData(uris[0]);
496         } else if (uris.length > 1) {
497             final ClipData clipData = new ClipData(
498                     null, mState.acceptMimes, new ClipData.Item(uris[0]));
499             for (int i = 1; i < uris.length; i++) {
500                 clipData.addItem(new ClipData.Item(uris[i]));
501             }
502             intent.setClipData(clipData);
503         }
504 
505         updatePickResult(
506                 intent, mSearchMgr.isSearching(), Metrics.sanitizeRoot(mState.stack.getRoot()));
507 
508         // TODO: Separate this piece of logic per action.
509         // We don't instantiate different objects for different actions at the first place, so it's
510         // not a easy task to separate this logic cleanly.
511         // Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its
512         // inheritance structure.
513         if (mState.action == ACTION_GET_CONTENT) {
514             intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
515         } else if (mState.action == ACTION_OPEN_TREE) {
516             intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
517                     | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
518                     | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
519                     | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
520         } else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
521             // Picking a copy destination is only used internally by us, so we
522             // don't need to extend permissions to the caller.
523             intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack);
524             intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType);
525         } else {
526             intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
527                     | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
528                     | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
529         }
530 
531         mActivity.setResult(FragmentActivity.RESULT_OK, intent, 0);
532         mActivity.finish();
533     }
534 
getExecutorForCurrentDirectory()535     private Executor getExecutorForCurrentDirectory() {
536         final DocumentInfo cwd = mState.stack.peek();
537         if (cwd != null && cwd.authority != null) {
538             return mExecutors.lookup(cwd.authority);
539         } else {
540             return AsyncTask.THREAD_POOL_EXECUTOR;
541         }
542     }
543 
544     public interface Addons extends CommonAddons {
545         @Override
onDocumentPicked(DocumentInfo doc)546         void onDocumentPicked(DocumentInfo doc);
547 
548         /**
549          * Overload final method {@link FragmentActivity#setResult(int, Intent)} so that we can
550          * intercept this method call in test environment.
551          */
552         @VisibleForTesting
setResult(int resultCode, Intent result, int notUsed)553         void setResult(int resultCode, Intent result, int notUsed);
554     }
555 }