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