Home | History | Annotate | Download | only in documentsui
      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;
     18 
     19 import static com.android.documentsui.base.DocumentInfo.getCursorInt;
     20 import static com.android.documentsui.base.DocumentInfo.getCursorString;
     21 import static com.android.documentsui.base.Shared.DEBUG;
     22 
     23 import android.app.Activity;
     24 import android.app.LoaderManager.LoaderCallbacks;
     25 import android.app.PendingIntent;
     26 import android.content.Context;
     27 import android.content.Intent;
     28 import android.content.IntentSender;
     29 import android.content.Loader;
     30 import android.content.pm.ResolveInfo;
     31 import android.database.Cursor;
     32 import android.graphics.drawable.ColorDrawable;
     33 import android.net.Uri;
     34 import android.os.Bundle;
     35 import android.os.Parcelable;
     36 import android.provider.DocumentsContract;
     37 import android.support.annotation.VisibleForTesting;
     38 import android.util.Log;
     39 import android.util.Pair;
     40 import android.view.DragEvent;
     41 
     42 import com.android.documentsui.AbstractActionHandler.CommonAddons;
     43 import com.android.documentsui.LoadDocStackTask.LoadDocStackCallback;
     44 import com.android.documentsui.base.BooleanConsumer;
     45 import com.android.documentsui.base.DocumentInfo;
     46 import com.android.documentsui.base.DocumentStack;
     47 import com.android.documentsui.base.Features;
     48 import com.android.documentsui.base.Lookup;
     49 import com.android.documentsui.base.Providers;
     50 import com.android.documentsui.base.RootInfo;
     51 import com.android.documentsui.base.Shared;
     52 import com.android.documentsui.base.State;
     53 import com.android.documentsui.dirlist.AnimationView;
     54 import com.android.documentsui.dirlist.AnimationView.AnimationType;
     55 import com.android.documentsui.dirlist.DocumentDetails;
     56 import com.android.documentsui.dirlist.FocusHandler;
     57 import com.android.documentsui.files.LauncherActivity;
     58 import com.android.documentsui.queries.SearchViewManager;
     59 import com.android.documentsui.roots.GetRootDocumentTask;
     60 import com.android.documentsui.roots.LoadRootTask;
     61 import com.android.documentsui.roots.ProvidersAccess;
     62 import com.android.documentsui.selection.Selection;
     63 import com.android.documentsui.selection.SelectionManager;
     64 import com.android.documentsui.sidebar.EjectRootTask;
     65 import com.android.documentsui.ui.Snackbars;
     66 
     67 import java.util.ArrayList;
     68 import java.util.List;
     69 import java.util.Objects;
     70 import java.util.concurrent.Executor;
     71 import java.util.function.Consumer;
     72 
     73 import javax.annotation.Nullable;
     74 
     75 /**
     76  * Provides support for specializing the actions (openDocument etc.) to the host activity.
     77  */
     78 public abstract class AbstractActionHandler<T extends Activity & CommonAddons>
     79         implements ActionHandler {
     80 
     81     @VisibleForTesting
     82     public static final int CODE_FORWARD = 42;
     83     public static final int CODE_AUTHENTICATION = 43;
     84 
     85     @VisibleForTesting
     86     static final int LOADER_ID = 42;
     87 
     88     private static final String TAG = "AbstractActionHandler";
     89     private static final int REFRESH_SPINNER_TIMEOUT = 500;
     90 
     91     protected final T mActivity;
     92     protected final State mState;
     93     protected final ProvidersAccess mProviders;
     94     protected final DocumentsAccess mDocs;
     95     protected final FocusHandler mFocusHandler;
     96     protected final SelectionManager mSelectionMgr;
     97     protected final SearchViewManager mSearchMgr;
     98     protected final Lookup<String, Executor> mExecutors;
     99     protected final Injector<?> mInjector;
    100 
    101     private final LoaderBindings mBindings;
    102 
    103     private Runnable mDisplayStateChangedListener;
    104 
    105     private DirectoryReloadLock mDirectoryReloadLock;
    106 
    107     @Override
    108     public void registerDisplayStateChangedListener(Runnable l) {
    109         mDisplayStateChangedListener = l;
    110     }
    111     @Override
    112     public void unregisterDisplayStateChangedListener(Runnable l) {
    113         if (mDisplayStateChangedListener == l) {
    114             mDisplayStateChangedListener = null;
    115         }
    116     }
    117 
    118     public AbstractActionHandler(
    119             T activity,
    120             State state,
    121             ProvidersAccess providers,
    122             DocumentsAccess docs,
    123             SearchViewManager searchMgr,
    124             Lookup<String, Executor> executors,
    125             Injector<?> injector) {
    126 
    127         assert(activity != null);
    128         assert(state != null);
    129         assert(providers != null);
    130         assert(searchMgr != null);
    131         assert(docs != null);
    132         assert(injector != null);
    133 
    134         mActivity = activity;
    135         mState = state;
    136         mProviders = providers;
    137         mDocs = docs;
    138         mFocusHandler = injector.focusManager;
    139         mSelectionMgr = injector.selectionMgr;
    140         mSearchMgr = searchMgr;
    141         mExecutors = executors;
    142         mInjector = injector;
    143 
    144         mBindings = new LoaderBindings();
    145     }
    146 
    147     @Override
    148     public void ejectRoot(RootInfo root, BooleanConsumer listener) {
    149         new EjectRootTask(
    150                 mActivity.getContentResolver(),
    151                 root.authority,
    152                 root.rootId,
    153                 listener).executeOnExecutor(ProviderExecutor.forAuthority(root.authority));
    154     }
    155 
    156     @Override
    157     public void startAuthentication(PendingIntent intent) {
    158         try {
    159             mActivity.startIntentSenderForResult(intent.getIntentSender(), CODE_AUTHENTICATION,
    160                     null, 0, 0, 0);
    161         } catch (IntentSender.SendIntentException cancelled) {
    162             Log.d(TAG, "Authentication Pending Intent either canceled or ignored.");
    163         }
    164     }
    165 
    166     @Override
    167     public void onActivityResult(int requestCode, int resultCode, Intent data) {
    168         switch (requestCode) {
    169             case CODE_AUTHENTICATION:
    170                 onAuthenticationResult(resultCode);
    171                 break;
    172         }
    173     }
    174 
    175     private void onAuthenticationResult(int resultCode) {
    176         if (resultCode == Activity.RESULT_OK) {
    177             Log.v(TAG, "Authentication was successful. Refreshing directory now.");
    178             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
    179         }
    180     }
    181 
    182     @Override
    183     public void getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback) {
    184         GetRootDocumentTask task = new GetRootDocumentTask(
    185                 root,
    186                 mActivity,
    187                 timeout,
    188                 mDocs,
    189                 callback);
    190 
    191         task.executeOnExecutor(mExecutors.lookup(root.authority));
    192     }
    193 
    194     @Override
    195     public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) {
    196         RefreshTask task = new RefreshTask(
    197                 mInjector.features,
    198                 mState,
    199                 doc,
    200                 REFRESH_SPINNER_TIMEOUT,
    201                 mActivity.getApplicationContext(),
    202                 mActivity::isDestroyed,
    203                 callback);
    204         task.executeOnExecutor(mExecutors.lookup(doc == null ? null : doc.authority));
    205     }
    206 
    207     @Override
    208     public void openSelectedInNewWindow() {
    209         throw new UnsupportedOperationException("Can't open in new window.");
    210     }
    211 
    212     @Override
    213     public void openInNewWindow(DocumentStack path) {
    214         Metrics.logUserAction(mActivity, Metrics.USER_ACTION_NEW_WINDOW);
    215 
    216         Intent intent = LauncherActivity.createLaunchIntent(mActivity);
    217         intent.putExtra(Shared.EXTRA_STACK, (Parcelable) path);
    218 
    219         // Multi-window necessitates we pick how we are launched.
    220         // By default we'd be launched in-place above the existing app.
    221         // By setting launch-to-side ActivityManager will open us to side.
    222         if (mActivity.isInMultiWindowMode()) {
    223             intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
    224         }
    225 
    226         mActivity.startActivity(intent);
    227     }
    228 
    229     @Override
    230     public boolean openDocument(DocumentDetails doc, @ViewType int type, @ViewType int fallback) {
    231         throw new UnsupportedOperationException("Can't open document.");
    232     }
    233 
    234     @Override
    235     public void springOpenDirectory(DocumentInfo doc) {
    236         throw new UnsupportedOperationException("Can't spring open directories.");
    237     }
    238 
    239     @Override
    240     public void openSettings(RootInfo root) {
    241         throw new UnsupportedOperationException("Can't open settings.");
    242     }
    243 
    244     @Override
    245     public void openRoot(ResolveInfo app) {
    246         throw new UnsupportedOperationException("Can't open an app.");
    247     }
    248 
    249     @Override
    250     public void showAppDetails(ResolveInfo info) {
    251         throw new UnsupportedOperationException("Can't show app details.");
    252     }
    253 
    254     @Override
    255     public boolean dropOn(DragEvent event, RootInfo root) {
    256         throw new UnsupportedOperationException("Can't open an app.");
    257     }
    258 
    259     @Override
    260     public void pasteIntoFolder(RootInfo root) {
    261         throw new UnsupportedOperationException("Can't paste into folder.");
    262     }
    263 
    264     @Override
    265     public void viewInOwner() {
    266         throw new UnsupportedOperationException("Can't view in application.");
    267     }
    268 
    269     @Override
    270     public void selectAllFiles() {
    271         Metrics.logUserAction(mActivity, Metrics.USER_ACTION_SELECT_ALL);
    272         Model model = mInjector.getModel();
    273 
    274         // Exclude disabled files
    275         List<String> enabled = new ArrayList<>();
    276         for (String id : model.getModelIds()) {
    277             Cursor cursor = model.getItem(id);
    278             if (cursor == null) {
    279                 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id);
    280                 continue;
    281             }
    282             String docMimeType = getCursorString(
    283                     cursor, DocumentsContract.Document.COLUMN_MIME_TYPE);
    284             int docFlags = getCursorInt(cursor, DocumentsContract.Document.COLUMN_FLAGS);
    285             if (mInjector.config.isDocumentEnabled(docMimeType, docFlags, mState)) {
    286                 enabled.add(id);
    287             }
    288         }
    289 
    290         // Only select things currently visible in the adapter.
    291         boolean changed = mSelectionMgr.setItemsSelected(enabled, true);
    292         if (changed) {
    293             mDisplayStateChangedListener.run();
    294         }
    295     }
    296 
    297     @Override
    298     @Nullable
    299     public DocumentInfo renameDocument(String name, DocumentInfo document) {
    300         throw new UnsupportedOperationException("Can't rename documents.");
    301     }
    302 
    303     @Override
    304     public void showChooserForDoc(DocumentInfo doc) {
    305         throw new UnsupportedOperationException("Show chooser for doc not supported!");
    306     }
    307 
    308     @Override
    309     public void openRootDocument(@Nullable DocumentInfo rootDoc) {
    310         if (rootDoc == null) {
    311             // There are 2 cases where rootDoc is null -- 1) loading recents; 2) failed to load root
    312             // document. Either case we should call refreshCurrentRootAndDirectory() to let
    313             // DirectoryFragment update UI.
    314             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
    315         } else {
    316             openContainerDocument(rootDoc);
    317         }
    318     }
    319 
    320     @Override
    321     public void openContainerDocument(DocumentInfo doc) {
    322         assert(doc.isContainer());
    323 
    324         if (mSearchMgr.isSearching()) {
    325             loadDocument(
    326                     doc.derivedUri,
    327                     (@Nullable DocumentStack stack) -> openFolderInSearchResult(stack, doc));
    328         } else {
    329             openChildContainer(doc);
    330         }
    331     }
    332 
    333     private void openFolderInSearchResult(@Nullable DocumentStack stack, DocumentInfo doc) {
    334         if (stack == null) {
    335             mState.stack.popToRootDocument();
    336 
    337             // Update navigator to give horizontal breadcrumb a chance to update documents. It
    338             // doesn't update its content if the size of document stack doesn't change.
    339             // TODO: update breadcrumb to take range update.
    340             mActivity.updateNavigator();
    341 
    342             mState.stack.push(doc);
    343         } else {
    344             if (!Objects.equals(mState.stack.getRoot(), stack.getRoot())) {
    345                 Log.w(TAG, "Provider returns " + stack.getRoot() + " rather than expected "
    346                         + mState.stack.getRoot());
    347             }
    348 
    349             mState.stack.reset();
    350             // Update navigator to give horizontal breadcrumb a chance to update documents. It
    351             // doesn't update its content if the size of document stack doesn't change.
    352             // TODO: update breadcrumb to take range update.
    353             mActivity.updateNavigator();
    354 
    355             mState.stack.reset(stack);
    356         }
    357 
    358         // Show an opening animation only if pressing "back" would get us back to the
    359         // previous directory. Especially after opening a root document, pressing
    360         // back, wouldn't go to the previous root, but close the activity.
    361         final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1)
    362                 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE;
    363         mActivity.refreshCurrentRootAndDirectory(anim);
    364     }
    365 
    366     private void openChildContainer(DocumentInfo doc) {
    367         DocumentInfo currentDoc = null;
    368 
    369         if (doc.isDirectory()) {
    370             // Regular directory.
    371             currentDoc = doc;
    372         } else if (doc.isArchive()) {
    373             // Archive.
    374             currentDoc = mDocs.getArchiveDocument(doc.derivedUri);
    375         }
    376 
    377         assert(currentDoc != null);
    378         mActivity.notifyDirectoryNavigated(currentDoc.derivedUri);
    379 
    380         mState.stack.push(currentDoc);
    381         // Show an opening animation only if pressing "back" would get us back to the
    382         // previous directory. Especially after opening a root document, pressing
    383         // back, wouldn't go to the previous root, but close the activity.
    384         final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1)
    385                 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE;
    386         mActivity.refreshCurrentRootAndDirectory(anim);
    387     }
    388 
    389     @Override
    390     public void setDebugMode(boolean enabled) {
    391         if (!mInjector.features.isDebugSupportEnabled()) {
    392             return;
    393         }
    394 
    395         mState.debugMode = enabled;
    396         mInjector.features.forceFeature(R.bool.feature_command_interceptor, enabled);
    397         mActivity.invalidateOptionsMenu();
    398 
    399         if (enabled) {
    400             showDebugMessage();
    401         } else {
    402             mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable(
    403                     mActivity.getResources().getColor(R.color.primary)));
    404             mActivity.getWindow().setStatusBarColor(
    405                     mActivity.getResources().getColor(R.color.primary_dark));
    406         }
    407     }
    408 
    409     @Override
    410     public void showDebugMessage() {
    411         assert (mInjector.features.isDebugSupportEnabled());
    412 
    413         int[] colors = mInjector.debugHelper.getNextColors();
    414         Pair<String, Integer> messagePair = mInjector.debugHelper.getNextMessage();
    415 
    416         Snackbars.showCustomTextWithImage(mActivity, messagePair.first, messagePair.second);
    417 
    418         mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable(colors[0]));
    419         mActivity.getWindow().setStatusBarColor(colors[1]);
    420     }
    421 
    422     @Override
    423     public void cutToClipboard() {
    424         throw new UnsupportedOperationException("Cut not supported!");
    425     }
    426 
    427     @Override
    428     public void copyToClipboard() {
    429         throw new UnsupportedOperationException("Copy not supported!");
    430     }
    431 
    432     @Override
    433     public void deleteSelectedDocuments() {
    434         throw new UnsupportedOperationException("Delete not supported!");
    435     }
    436 
    437     @Override
    438     public void shareSelectedDocuments() {
    439         throw new UnsupportedOperationException("Share not supported!");
    440     }
    441 
    442     protected final void loadDocument(Uri uri, LoadDocStackCallback callback) {
    443         new LoadDocStackTask(
    444                 mActivity,
    445                 mProviders,
    446                 mDocs,
    447                 callback
    448                 ).executeOnExecutor(mExecutors.lookup(uri.getAuthority()), uri);
    449     }
    450 
    451     @Override
    452     public final void loadRoot(Uri uri) {
    453         new LoadRootTask<>(mActivity, mProviders, mState, uri)
    454                 .executeOnExecutor(mExecutors.lookup(uri.getAuthority()));
    455     }
    456 
    457     @Override
    458     public void loadDocumentsForCurrentStack() {
    459         DocumentStack stack = mState.stack;
    460         if (!stack.isRecents() && stack.isEmpty()) {
    461             DirectoryResult result = new DirectoryResult();
    462 
    463             // TODO (b/35996595): Consider plumbing through the actual exception, though it might
    464             // not be very useful (always pointing to DatabaseUtils#readExceptionFromParcel()).
    465             result.exception = new IllegalStateException("Failed to load root document.");
    466             mInjector.getModel().update(result);
    467             return;
    468         }
    469 
    470         mActivity.getLoaderManager().restartLoader(LOADER_ID, null, mBindings);
    471     }
    472 
    473     protected final boolean launchToDocument(Uri uri) {
    474         // We don't support launching to a document in an archive.
    475         if (!Providers.isArchiveUri(uri)) {
    476             loadDocument(uri, this::onStackLoaded);
    477             return true;
    478         }
    479 
    480         return false;
    481     }
    482 
    483     private void onStackLoaded(@Nullable DocumentStack stack) {
    484         if (stack != null) {
    485             if (!stack.peek().isDirectory()) {
    486                 // Requested document is not a directory. Pop it so that we can launch into its
    487                 // parent.
    488                 stack.pop();
    489             }
    490             mState.stack.reset(stack);
    491             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
    492 
    493             Metrics.logLaunchAtLocation(mActivity, mState, stack.getRoot().getUri());
    494         } else {
    495             Log.w(TAG, "Failed to launch into the given uri. Launch to default location.");
    496             launchToDefaultLocation();
    497 
    498             Metrics.logLaunchAtLocation(mActivity, mState, null);
    499         }
    500     }
    501 
    502     protected abstract void launchToDefaultLocation();
    503 
    504     protected final void loadHomeDir() {
    505         loadRoot(Shared.getDefaultRootUri(mActivity));
    506     }
    507 
    508     protected Selection getStableSelection() {
    509         return mSelectionMgr.getSelection(new Selection());
    510     }
    511 
    512     @Override
    513     public ActionHandler reset(DirectoryReloadLock reloadLock) {
    514         mDirectoryReloadLock = reloadLock;
    515         mActivity.getLoaderManager().destroyLoader(LOADER_ID);
    516         return this;
    517     }
    518 
    519     private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> {
    520 
    521         @Override
    522         public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
    523             Context context = mActivity;
    524 
    525             if (mState.stack.isRecents()) {
    526 
    527                 if (DEBUG) Log.d(TAG, "Creating new loader recents.");
    528                 return new RecentsLoader(context, mProviders, mState, mInjector.features);
    529 
    530             } else {
    531 
    532                 Uri contentsUri = mSearchMgr.isSearching()
    533                         ? DocumentsContract.buildSearchDocumentsUri(
    534                             mState.stack.getRoot().authority,
    535                             mState.stack.getRoot().rootId,
    536                             mSearchMgr.getCurrentSearch())
    537                         : DocumentsContract.buildChildDocumentsUri(
    538                                 mState.stack.peek().authority,
    539                                 mState.stack.peek().documentId);
    540 
    541                 if (mInjector.config.managedModeEnabled(mState.stack)) {
    542                     contentsUri = DocumentsContract.setManageMode(contentsUri);
    543                 }
    544 
    545                 if (DEBUG) Log.d(TAG,
    546                         "Creating new directory loader for: "
    547                                 + DocumentInfo.debugString(mState.stack.peek()));
    548 
    549                 return new DirectoryLoader(
    550                         mInjector.features,
    551                         context,
    552                         mState.stack.getRoot(),
    553                         mState.stack.peek(),
    554                         contentsUri,
    555                         mState.sortModel,
    556                         mDirectoryReloadLock,
    557                         mSearchMgr.isSearching());
    558             }
    559         }
    560 
    561         @Override
    562         public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
    563             if (DEBUG) Log.d(TAG, "Loader has finished for: "
    564                     + DocumentInfo.debugString(mState.stack.peek()));
    565             assert(result != null);
    566 
    567             mInjector.getModel().update(result);
    568         }
    569 
    570         @Override
    571         public void onLoaderReset(Loader<DirectoryResult> loader) {}
    572     }
    573     /**
    574      * A class primarily for the support of isolating our tests
    575      * from our concrete activity implementations.
    576      */
    577     public interface CommonAddons {
    578         void refreshCurrentRootAndDirectory(@AnimationType int anim);
    579         void onRootPicked(RootInfo root);
    580         // TODO: Move this to PickAddons as multi-document picking is exclusive to that activity.
    581         void onDocumentsPicked(List<DocumentInfo> docs);
    582         void onDocumentPicked(DocumentInfo doc);
    583         RootInfo getCurrentRoot();
    584         DocumentInfo getCurrentDirectory();
    585         void setRootsDrawerOpen(boolean open);
    586 
    587         // TODO: Let navigator listens to State
    588         void updateNavigator();
    589 
    590         @VisibleForTesting
    591         void notifyDirectoryNavigated(Uri docUri);
    592     }
    593 }
    594