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