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
17package com.android.documentsui.picker;
18
19import static com.android.documentsui.base.Shared.DEBUG;
20import static com.android.documentsui.base.State.ACTION_CREATE;
21import static com.android.documentsui.base.State.ACTION_GET_CONTENT;
22import static com.android.documentsui.base.State.ACTION_OPEN;
23import static com.android.documentsui.base.State.ACTION_OPEN_TREE;
24import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION;
25
26import android.app.Activity;
27import android.app.FragmentManager;
28import android.content.ClipData;
29import android.content.ComponentName;
30import android.content.Intent;
31import android.content.pm.ResolveInfo;
32import android.net.Uri;
33import android.os.AsyncTask;
34import android.os.Parcelable;
35import android.provider.DocumentsContract;
36import android.provider.Settings;
37import android.util.Log;
38
39import com.android.documentsui.AbstractActionHandler;
40import com.android.documentsui.ActivityConfig;
41import com.android.documentsui.DocumentsAccess;
42import com.android.documentsui.Injector;
43import com.android.documentsui.Metrics;
44import com.android.documentsui.base.BooleanConsumer;
45import com.android.documentsui.base.DocumentInfo;
46import com.android.documentsui.base.DocumentStack;
47import com.android.documentsui.base.Features;
48import com.android.documentsui.base.Lookup;
49import com.android.documentsui.base.RootInfo;
50import com.android.documentsui.base.Shared;
51import com.android.documentsui.base.State;
52import com.android.documentsui.dirlist.AnimationView;
53import com.android.documentsui.dirlist.DocumentDetails;
54import com.android.documentsui.Model;
55import com.android.documentsui.picker.ActionHandler.Addons;
56import com.android.documentsui.queries.SearchViewManager;
57import com.android.documentsui.roots.ProvidersAccess;
58import com.android.documentsui.services.FileOperationService;
59import com.android.internal.annotations.VisibleForTesting;
60
61import java.util.Arrays;
62import java.util.concurrent.Executor;
63
64import javax.annotation.Nullable;
65
66/**
67 * Provides {@link PickActivity} action specializations to fragments.
68 */
69class ActionHandler<T extends Activity & Addons> extends AbstractActionHandler<T> {
70
71    private static final String TAG = "PickerActionHandler";
72
73    private final Features mFeatures;
74    private final ActivityConfig mConfig;
75    private final Model mModel;
76    private final LastAccessedStorage mLastAccessed;
77
78    ActionHandler(
79            T activity,
80            State state,
81            ProvidersAccess providers,
82            DocumentsAccess docs,
83            SearchViewManager searchMgr,
84            Lookup<String, Executor> executors,
85            Injector injector,
86            LastAccessedStorage lastAccessed) {
87
88        super(activity, state, providers, docs, searchMgr, executors, injector);
89
90        mConfig = injector.config;
91        mFeatures = injector.features;
92        mModel = injector.getModel();
93        mLastAccessed = lastAccessed;
94    }
95
96    @Override
97    public void initLocation(Intent intent) {
98        assert(intent != null);
99
100        // stack is initialized if it's restored from bundle, which means we're restoring a
101        // previously stored state.
102        if (mState.stack.isInitialized()) {
103            if (DEBUG) Log.d(TAG, "Stack already resolved for uri: " + intent.getData());
104            return;
105        }
106
107        // We set the activity title in AsyncTask.onPostExecute().
108        // To prevent talkback from reading aloud the default title, we clear it here.
109        mActivity.setTitle("");
110
111        if (launchHomeForCopyDestination(intent)) {
112            if (DEBUG) Log.d(TAG, "Launching directly into Home directory for copy destination.");
113            return;
114        }
115
116        if (mFeatures.isLaunchToDocumentEnabled() && launchToDocument(intent)) {
117            if (DEBUG) Log.d(TAG, "Launched to a document.");
118            return;
119        }
120
121        if (DEBUG) Log.d(TAG, "Load last accessed stack.");
122        loadLastAccessedStack();
123    }
124
125    @Override
126    protected void launchToDefaultLocation() {
127        loadLastAccessedStack();
128    }
129
130    private boolean launchHomeForCopyDestination(Intent intent) {
131        // As a matter of policy we don't load the last used stack for the copy
132        // destination picker (user is already in Files app).
133        // Consensus was that the experice was too confusing.
134        // In all other cases, where the user is visiting us from another app
135        // we restore the stack as last used from that app.
136        if (Shared.ACTION_PICK_COPY_DESTINATION.equals(intent.getAction())) {
137            loadHomeDir();
138            return true;
139        }
140
141        return false;
142    }
143
144    private boolean launchToDocument(Intent intent) {
145        Uri uri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI);
146        if (uri != null) {
147            return launchToDocument(uri);
148        }
149
150        return false;
151    }
152
153    private void loadLastAccessedStack() {
154        if (DEBUG) Log.d(TAG, "Attempting to load last used stack for calling package.");
155        new LoadLastAccessedStackTask<>(
156                mActivity, mLastAccessed, mState, mProviders, this::onLastAccessedStackLoaded)
157                .execute();
158    }
159
160    private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) {
161        if (stack == null) {
162            loadDefaultLocation();
163        } else {
164            mState.stack.reset(stack);
165            mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
166        }
167    }
168
169    private void loadDefaultLocation() {
170        switch (mState.action) {
171            case ACTION_CREATE:
172                loadHomeDir();
173                break;
174            case ACTION_GET_CONTENT:
175            case ACTION_OPEN:
176            case ACTION_OPEN_TREE:
177                mState.stack.changeRoot(mProviders.getRecentsRoot());
178                mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
179                break;
180            default:
181                throw new UnsupportedOperationException("Unexpected action type: " + mState.action);
182        }
183    }
184
185    @Override
186    public void showAppDetails(ResolveInfo info) {
187        final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
188        intent.setData(Uri.fromParts("package", info.activityInfo.packageName, null));
189        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
190        mActivity.startActivity(intent);
191    }
192
193    @Override
194    public void onActivityResult(int requestCode, int resultCode, Intent data) {
195        if (DEBUG) Log.d(TAG, "onActivityResult() code=" + resultCode);
196
197        // Only relay back results when not canceled; otherwise stick around to
198        // let the user pick another app/backend.
199        switch (requestCode) {
200            case CODE_FORWARD:
201                onExternalAppResult(resultCode, data);
202                break;
203            default:
204                super.onActivityResult(requestCode, resultCode, data);
205        }
206    }
207
208    private void onExternalAppResult(int resultCode, Intent data) {
209        if (resultCode != Activity.RESULT_CANCELED) {
210            // Remember that we last picked via external app
211            mLastAccessed.setLastAccessedToExternalApp(mActivity);
212
213            // Pass back result to original caller
214            mActivity.setResult(resultCode, data, 0);
215            mActivity.finish();
216        }
217    }
218
219    @Override
220    public void openInNewWindow(DocumentStack path) {
221        // Open new window support only depends on vanilla Activity, so it is
222        // implemented in our parent class. But we don't support that in
223        // picking. So as a matter of defensiveness, we override that here.
224        throw new UnsupportedOperationException("Can't open in new window");
225    }
226
227    @Override
228    public void openRoot(RootInfo root) {
229        Metrics.logRootVisited(mActivity, Metrics.PICKER_SCOPE, root);
230        mActivity.onRootPicked(root);
231    }
232
233    @Override
234    public void openRoot(ResolveInfo info) {
235        Metrics.logAppVisited(mActivity, info);
236        final Intent intent = new Intent(mActivity.getIntent());
237        intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_FORWARD_RESULT);
238        intent.setComponent(new ComponentName(
239                info.activityInfo.applicationInfo.packageName, info.activityInfo.name));
240        mActivity.startActivityForResult(intent, CODE_FORWARD);
241    }
242
243    @Override
244    public void springOpenDirectory(DocumentInfo doc) {
245    }
246
247    @Override
248    public boolean openDocument(DocumentDetails details, @ViewType int type,
249            @ViewType int fallback) {
250        DocumentInfo doc = mModel.getDocument(details.getModelId());
251        if (doc == null) {
252            Log.w(TAG,
253                    "Can't view item. No Document available for modeId: " + details.getModelId());
254            return false;
255        }
256
257        if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) {
258            mActivity.onDocumentPicked(doc);
259            mSelectionMgr.clearSelection();
260            return true;
261        }
262        return false;
263    }
264
265    void pickDocument(DocumentInfo pickTarget) {
266        assert(pickTarget != null);
267        Uri result;
268        switch (mState.action) {
269            case ACTION_OPEN_TREE:
270                result = DocumentsContract.buildTreeDocumentUri(
271                        pickTarget.authority, pickTarget.documentId);
272                break;
273            case ACTION_PICK_COPY_DESTINATION:
274                result = pickTarget.derivedUri;
275                break;
276            default:
277                // Should not be reached
278                throw new IllegalStateException("Invalid mState.action");
279        }
280        finishPicking(result);
281    }
282
283    void saveDocument(
284            String mimeType, String displayName, BooleanConsumer inProgressStateListener) {
285        assert(mState.action == ACTION_CREATE);
286        new CreatePickedDocumentTask(
287                mActivity,
288                mDocs,
289                mLastAccessed,
290                mState.stack,
291                mimeType,
292                displayName,
293                inProgressStateListener,
294                this::onPickFinished)
295                .executeOnExecutor(getExecutorForCurrentDirectory());
296    }
297
298    // User requested to overwrite a target. If confirmed by user #finishPicking() will be
299    // called.
300    void saveDocument(FragmentManager fm, DocumentInfo replaceTarget) {
301        assert(mState.action == ACTION_CREATE);
302        assert(replaceTarget != null);
303
304        mInjector.dialogs.confirmOverwrite(fm, replaceTarget);
305    }
306
307    void finishPicking(Uri... docs) {
308        new SetLastAccessedStackTask(
309                mActivity,
310                mLastAccessed,
311                mState.stack,
312                () -> {
313                    onPickFinished(docs);
314                }
315        ) .executeOnExecutor(getExecutorForCurrentDirectory());
316    }
317
318    private void onPickFinished(Uri... uris) {
319        if (DEBUG) Log.d(TAG, "onFinished() " + Arrays.toString(uris));
320
321        final Intent intent = new Intent();
322        if (uris.length == 1) {
323            intent.setData(uris[0]);
324        } else if (uris.length > 1) {
325            final ClipData clipData = new ClipData(
326                    null, mState.acceptMimes, new ClipData.Item(uris[0]));
327            for (int i = 1; i < uris.length; i++) {
328                clipData.addItem(new ClipData.Item(uris[i]));
329            }
330            intent.setClipData(clipData);
331        }
332
333        // TODO: Separate this piece of logic per action.
334        // We don't instantiate different objects for different actions at the first place, so it's
335        // not a easy task to separate this logic cleanly.
336        // Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its
337        // inheritance structure.
338        if (mState.action == ACTION_GET_CONTENT) {
339            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
340        } else if (mState.action == ACTION_OPEN_TREE) {
341            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
342                    | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
343                    | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
344                    | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
345        } else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
346            // Picking a copy destination is only used internally by us, so we
347            // don't need to extend permissions to the caller.
348            intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack);
349            intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType);
350        } else {
351            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
352                    | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
353                    | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
354        }
355
356        mActivity.setResult(Activity.RESULT_OK, intent, 0);
357        mActivity.finish();
358    }
359
360    private Executor getExecutorForCurrentDirectory() {
361        final DocumentInfo cwd = mState.stack.peek();
362        if (cwd != null && cwd.authority != null) {
363            return mExecutors.lookup(cwd.authority);
364        } else {
365            return AsyncTask.THREAD_POOL_EXECUTOR;
366        }
367    }
368
369    public interface Addons extends CommonAddons {
370        void onDocumentPicked(DocumentInfo doc);
371
372        /**
373         * Overload final method {@link Activity#setResult(int, Intent)} so that we can intercept
374         * this method call in test environment.
375         */
376        @VisibleForTesting
377        void setResult(int resultCode, Intent result, int notUsed);
378    }
379}
380