• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.SharedMinimal.DEBUG;
22 import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled;
23 import static com.android.documentsui.util.FlagUtils.isUseSearchV2FlagEnabled;
24 import static com.android.documentsui.util.FlagUtils.isZipNgFlagEnabled;
25 
26 import android.app.PendingIntent;
27 import android.content.ActivityNotFoundException;
28 import android.content.ComponentName;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.IntentSender;
32 import android.content.pm.PackageManager;
33 import android.content.pm.ResolveInfo;
34 import android.database.Cursor;
35 import android.net.Uri;
36 import android.os.Bundle;
37 import android.os.Parcelable;
38 import android.provider.DocumentsContract;
39 import android.util.Log;
40 import android.util.Pair;
41 import android.view.DragEvent;
42 
43 import androidx.annotation.NonNull;
44 import androidx.annotation.VisibleForTesting;
45 import androidx.fragment.app.FragmentActivity;
46 import androidx.loader.app.LoaderManager.LoaderCallbacks;
47 import androidx.loader.content.Loader;
48 import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
49 import androidx.recyclerview.selection.MutableSelection;
50 import androidx.recyclerview.selection.SelectionTracker;
51 
52 import com.android.documentsui.AbstractActionHandler.CommonAddons;
53 import com.android.documentsui.LoadDocStackTask.LoadDocStackCallback;
54 import com.android.documentsui.base.BooleanConsumer;
55 import com.android.documentsui.base.DocumentInfo;
56 import com.android.documentsui.base.DocumentStack;
57 import com.android.documentsui.base.Lookup;
58 import com.android.documentsui.base.MimeTypes;
59 import com.android.documentsui.base.Providers;
60 import com.android.documentsui.base.RootInfo;
61 import com.android.documentsui.base.Shared;
62 import com.android.documentsui.base.State;
63 import com.android.documentsui.base.UserId;
64 import com.android.documentsui.dirlist.AnimationView;
65 import com.android.documentsui.dirlist.AnimationView.AnimationType;
66 import com.android.documentsui.dirlist.FocusHandler;
67 import com.android.documentsui.files.LauncherActivity;
68 import com.android.documentsui.files.QuickViewIntentBuilder;
69 import com.android.documentsui.loaders.FolderLoader;
70 import com.android.documentsui.loaders.QueryOptions;
71 import com.android.documentsui.loaders.SearchLoader;
72 import com.android.documentsui.queries.SearchViewManager;
73 import com.android.documentsui.roots.GetRootDocumentTask;
74 import com.android.documentsui.roots.LoadFirstRootTask;
75 import com.android.documentsui.roots.LoadRootTask;
76 import com.android.documentsui.roots.ProvidersAccess;
77 import com.android.documentsui.sidebar.EjectRootTask;
78 import com.android.documentsui.sorting.SortListFragment;
79 import com.android.documentsui.ui.DialogController;
80 import com.android.documentsui.ui.Snackbars;
81 
82 import java.time.Duration;
83 import java.util.ArrayList;
84 import java.util.Collection;
85 import java.util.List;
86 import java.util.Objects;
87 import java.util.concurrent.Executor;
88 import java.util.concurrent.ExecutorService;
89 import java.util.concurrent.Executors;
90 import java.util.concurrent.Semaphore;
91 import java.util.function.Consumer;
92 
93 import javax.annotation.Nullable;
94 
95 /**
96  * Provides support for specializing the actions (openDocument etc.) to the host activity.
97  */
98 public abstract class AbstractActionHandler<T extends FragmentActivity & CommonAddons>
99         implements ActionHandler {
100 
101     @VisibleForTesting
102     public static final int CODE_AUTHENTICATION = 43;
103 
104     @VisibleForTesting
105     static final int LOADER_ID = 42;
106 
107     private static final String TAG = "AbstractActionHandler";
108     private static final int REFRESH_SPINNER_TIMEOUT = 500;
109     private final Semaphore mLoaderSemaphore = new Semaphore(1);
110 
111     protected final T mActivity;
112     protected final State mState;
113     protected final ProvidersAccess mProviders;
114     protected final DocumentsAccess mDocs;
115     protected final FocusHandler mFocusHandler;
116     protected final SelectionTracker<String> mSelectionMgr;
117     protected final SearchViewManager mSearchMgr;
118     protected final Lookup<String, Executor> mExecutors;
119     protected final DialogController mDialogs;
120     protected final Model mModel;
121     protected final Injector<?> mInjector;
122 
123     private final LoaderBindings mBindings;
124 
125     private Runnable mDisplayStateChangedListener;
126 
127     private ContentLock mContentLock;
128 
129     @Override
registerDisplayStateChangedListener(Runnable l)130     public void registerDisplayStateChangedListener(Runnable l) {
131         mDisplayStateChangedListener = l;
132     }
133 
134     @Override
unregisterDisplayStateChangedListener(Runnable l)135     public void unregisterDisplayStateChangedListener(Runnable l) {
136         if (mDisplayStateChangedListener == l) {
137             mDisplayStateChangedListener = null;
138         }
139     }
140 
AbstractActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, Injector<?> injector)141     public AbstractActionHandler(
142             T activity,
143             State state,
144             ProvidersAccess providers,
145             DocumentsAccess docs,
146             SearchViewManager searchMgr,
147             Lookup<String, Executor> executors,
148             Injector<?> injector) {
149 
150         assert (activity != null);
151         assert (state != null);
152         assert (providers != null);
153         assert (searchMgr != null);
154         assert (docs != null);
155         assert (injector != null);
156 
157         mActivity = activity;
158         mState = state;
159         mProviders = providers;
160         mDocs = docs;
161         mFocusHandler = injector.focusManager;
162         mSelectionMgr = injector.selectionMgr;
163         mSearchMgr = searchMgr;
164         mExecutors = executors;
165         mDialogs = injector.dialogs;
166         mModel = injector.getModel();
167         mInjector = injector;
168 
169         mBindings = new LoaderBindings();
170     }
171 
172     @Override
ejectRoot(RootInfo root, BooleanConsumer listener)173     public void ejectRoot(RootInfo root, BooleanConsumer listener) {
174         new EjectRootTask(
175                 mActivity.getContentResolver(),
176                 root.authority,
177                 root.rootId,
178                 listener).executeOnExecutor(ProviderExecutor.forAuthority(root.authority));
179     }
180 
181     @Override
startAuthentication(PendingIntent intent)182     public void startAuthentication(PendingIntent intent) {
183         try {
184             mActivity.startIntentSenderForResult(intent.getIntentSender(), CODE_AUTHENTICATION,
185                     null, 0, 0, 0);
186         } catch (IntentSender.SendIntentException cancelled) {
187             Log.d(TAG, "Authentication Pending Intent either canceled or ignored.");
188         }
189     }
190 
191     @Override
requestQuietModeDisabled(RootInfo info, UserId userId)192     public void requestQuietModeDisabled(RootInfo info, UserId userId) {
193         new RequestQuietModeDisabledTask(mActivity, userId).execute();
194     }
195 
196     @Override
onActivityResult(int requestCode, int resultCode, Intent data)197     public void onActivityResult(int requestCode, int resultCode, Intent data) {
198         switch (requestCode) {
199             case CODE_AUTHENTICATION:
200                 onAuthenticationResult(resultCode);
201                 break;
202         }
203     }
204 
onAuthenticationResult(int resultCode)205     private void onAuthenticationResult(int resultCode) {
206         if (resultCode == FragmentActivity.RESULT_OK) {
207             Log.v(TAG, "Authentication was successful. Refreshing directory now.");
208             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
209         }
210     }
211 
212     @Override
getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback)213     public void getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback) {
214         GetRootDocumentTask task = new GetRootDocumentTask(
215                 root,
216                 mActivity,
217                 timeout,
218                 mDocs,
219                 callback);
220 
221         task.executeOnExecutor(mExecutors.lookup(root.authority));
222     }
223 
224     @Override
refreshDocument(DocumentInfo doc, BooleanConsumer callback)225     public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) {
226         RefreshTask task = new RefreshTask(
227                 mInjector.features,
228                 mState,
229                 doc,
230                 REFRESH_SPINNER_TIMEOUT,
231                 mActivity.getApplicationContext(),
232                 mActivity::isDestroyed,
233                 callback);
234         task.executeOnExecutor(mExecutors.lookup(doc == null ? null : doc.authority));
235     }
236 
237     @Override
openSelectedInNewWindow()238     public void openSelectedInNewWindow() {
239         throw new UnsupportedOperationException("Can't open in new window.");
240     }
241 
242     @Override
openInNewWindow(DocumentStack path)243     public void openInNewWindow(DocumentStack path) {
244         Metrics.logUserAction(MetricConsts.USER_ACTION_NEW_WINDOW);
245 
246         Intent intent = LauncherActivity.createLaunchIntent(mActivity);
247         intent.putExtra(Shared.EXTRA_STACK, (Parcelable) path);
248 
249         // Multi-window necessitates we pick how we are launched.
250         // By default we'd be launched in-place above the existing app.
251         // By setting launch-to-side ActivityManager will open us to side.
252         if (mActivity.isInMultiWindowMode()) {
253             intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
254         }
255 
256         mActivity.startActivity(intent);
257     }
258 
259     @Override
openItem(ItemDetails<String> doc, @ViewType int type, @ViewType int fallback)260     public boolean openItem(ItemDetails<String> doc, @ViewType int type, @ViewType int fallback) {
261         throw new UnsupportedOperationException("Can't open document.");
262     }
263 
264     @Override
openDocumentViewOnly(DocumentInfo doc)265     public void openDocumentViewOnly(DocumentInfo doc) {
266         throw new UnsupportedOperationException("Open doc not supported!");
267     }
268 
269     @Override
showPreview(DocumentInfo doc)270     public void showPreview(DocumentInfo doc) {
271         throw new UnsupportedOperationException("Can't open properties.");
272     }
273 
274     @Override
springOpenDirectory(DocumentInfo doc)275     public void springOpenDirectory(DocumentInfo doc) {
276         throw new UnsupportedOperationException("Can't spring open directories.");
277     }
278 
279     @Override
openSettings(RootInfo root)280     public void openSettings(RootInfo root) {
281         throw new UnsupportedOperationException("Can't open settings.");
282     }
283 
284     @Override
openRoot(ResolveInfo app, UserId userId)285     public void openRoot(ResolveInfo app, UserId userId) {
286         throw new UnsupportedOperationException("Can't open an app.");
287     }
288 
289     @Override
showAppDetails(ResolveInfo info, UserId userId)290     public void showAppDetails(ResolveInfo info, UserId userId) {
291         throw new UnsupportedOperationException("Can't show app details.");
292     }
293 
294     @Override
dropOn(DragEvent event, RootInfo root)295     public boolean dropOn(DragEvent event, RootInfo root) {
296         throw new UnsupportedOperationException("Can't open an app.");
297     }
298 
299     @Override
pasteIntoFolder(RootInfo root)300     public void pasteIntoFolder(RootInfo root) {
301         throw new UnsupportedOperationException("Can't paste into folder.");
302     }
303 
304     @Override
viewInOwner()305     public void viewInOwner() {
306         throw new UnsupportedOperationException("Can't view in application.");
307     }
308 
309     @Override
selectAllFiles()310     public void selectAllFiles() {
311         Metrics.logUserAction(MetricConsts.USER_ACTION_SELECT_ALL);
312         Model model = mInjector.getModel();
313 
314         // Exclude disabled files
315         List<String> enabled = new ArrayList<>();
316         for (String id : model.getModelIds()) {
317             Cursor cursor = model.getItem(id);
318             if (cursor == null) {
319                 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id);
320                 continue;
321             }
322             String docMimeType = getCursorString(
323                     cursor, DocumentsContract.Document.COLUMN_MIME_TYPE);
324             int docFlags = getCursorInt(cursor, DocumentsContract.Document.COLUMN_FLAGS);
325             if (mInjector.config.isDocumentEnabled(docMimeType, docFlags, mState)) {
326                 enabled.add(id);
327             }
328         }
329 
330         // Only select things currently visible in the adapter.
331         boolean changed = mSelectionMgr.setItemsSelected(enabled, true);
332         if (changed) {
333             mDisplayStateChangedListener.run();
334         }
335     }
336 
337     @Override
deselectAllFiles()338     public void deselectAllFiles() {
339         mSelectionMgr.clearSelection();
340     }
341 
342     @Override
showCreateDirectoryDialog()343     public void showCreateDirectoryDialog() {
344         Metrics.logUserAction(MetricConsts.USER_ACTION_CREATE_DIR);
345 
346         CreateDirectoryFragment.show(mActivity.getSupportFragmentManager());
347     }
348 
349     @Override
showSortDialog()350     public void showSortDialog() {
351         SortListFragment.show(mActivity.getSupportFragmentManager(), mState.sortModel);
352     }
353 
354     @Override
355     @Nullable
renameDocument(String name, DocumentInfo document)356     public DocumentInfo renameDocument(String name, DocumentInfo document) {
357         throw new UnsupportedOperationException("Can't rename documents.");
358     }
359 
360     @Override
showChooserForDoc(DocumentInfo doc)361     public void showChooserForDoc(DocumentInfo doc) {
362         throw new UnsupportedOperationException("Show chooser for doc not supported!");
363     }
364 
365     @Override
openRootDocument(@ullable DocumentInfo rootDoc)366     public void openRootDocument(@Nullable DocumentInfo rootDoc) {
367         if (rootDoc == null) {
368             // There are 2 cases where rootDoc is null -- 1) loading recents; 2) failed to load root
369             // document. Either case we should call refreshCurrentRootAndDirectory() to let
370             // DirectoryFragment update UI.
371             mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
372         } else {
373             openContainerDocument(rootDoc);
374         }
375     }
376 
377     @Override
openContainerDocument(DocumentInfo doc)378     public void openContainerDocument(DocumentInfo doc) {
379         assert (doc.isContainer());
380 
381         if (mSearchMgr.isSearching()) {
382             loadDocument(
383                     doc.derivedUri,
384                     doc.userId,
385                     (@Nullable DocumentStack stack) -> openFolderInSearchResult(stack, doc));
386         } else {
387             openChildContainer(doc);
388         }
389     }
390 
391     // TODO: Make this private and make tests call interface method instead.
392 
393     /**
394      * Behavior when a document is opened.
395      */
396     @VisibleForTesting
onDocumentOpened(DocumentInfo doc, @ViewType int type, @ViewType int fallback, boolean fromPicker)397     public void onDocumentOpened(DocumentInfo doc, @ViewType int type, @ViewType int fallback,
398             boolean fromPicker) {
399         // In picker mode, don't access archive container to avoid pick file in archive files.
400         if (doc.isContainer() && !fromPicker) {
401             openContainerDocument(doc);
402             return;
403         }
404 
405         if (manageDocument(doc)) {
406             return;
407         }
408 
409         // For APKs, even if the type is preview, we send an ACTION_VIEW intent to allow
410         // PackageManager to install it.  This allows users to install APKs from any root.
411         // The Downloads special case is handled above in #manageDocument.
412         if (MimeTypes.isApkType(doc.mimeType)) {
413             viewDocument(doc);
414             return;
415         }
416 
417         switch (type) {
418             case VIEW_TYPE_REGULAR:
419                 if (viewDocument(doc)) {
420                     return;
421                 }
422                 break;
423 
424             case VIEW_TYPE_PREVIEW:
425                 if (previewDocument(doc, fromPicker)) {
426                     return;
427                 }
428                 break;
429 
430             default:
431                 throw new IllegalArgumentException("Illegal view type.");
432         }
433 
434         switch (fallback) {
435             case VIEW_TYPE_REGULAR:
436                 if (viewDocument(doc)) {
437                     return;
438                 }
439                 break;
440 
441             case VIEW_TYPE_PREVIEW:
442                 if (previewDocument(doc, fromPicker)) {
443                     return;
444                 }
445                 break;
446 
447             case VIEW_TYPE_NONE:
448                 break;
449 
450             default:
451                 throw new IllegalArgumentException("Illegal fallback view type.");
452         }
453 
454         // Failed to view including fallback, and it's in an archive.
455         if (type != VIEW_TYPE_NONE && fallback != VIEW_TYPE_NONE && doc.isInArchive()) {
456             mDialogs.showViewInArchivesUnsupported();
457         }
458     }
459 
viewDocument(DocumentInfo doc)460     private boolean viewDocument(DocumentInfo doc) {
461         if (doc.isPartial()) {
462             Log.w(TAG, "Cannot view partial file");
463             return false;
464         }
465 
466         if (!isZipNgFlagEnabled() && doc.isInArchive()) {
467             Log.w(TAG, "Cannot view file in archive");
468             return false;
469         }
470 
471         if (doc.isDirectory()) {
472             Log.w(TAG, "Cannot view directory");
473             return true;
474         }
475 
476         Intent intent = buildViewIntent(doc);
477         if (DEBUG && intent.getClipData() != null) {
478             Log.d(TAG, "Starting intent w/ clip data: " + intent.getClipData());
479         }
480 
481         try {
482             doc.userId.startActivityAsUser(mActivity, intent);
483             return true;
484         } catch (ActivityNotFoundException e) {
485             mDialogs.showNoApplicationFound();
486         }
487         return false;
488     }
489 
previewDocument(DocumentInfo doc, boolean fromPicker)490     private boolean previewDocument(DocumentInfo doc, boolean fromPicker) {
491         if (doc.isPartial()) {
492             Log.w(TAG, "Can't view partial file.");
493             return false;
494         }
495 
496         Intent intent = new QuickViewIntentBuilder(
497                 mActivity,
498                 mActivity.getResources(),
499                 doc,
500                 mModel,
501                 fromPicker).build();
502 
503         if (intent != null) {
504             // TODO: un-work around issue b/24963914. Should be fixed soon.
505             try {
506                 doc.userId.startActivityAsUser(mActivity, intent);
507                 return true;
508             } catch (SecurityException e) {
509                 // Carry on to regular view mode.
510                 Log.e(TAG, "Caught security error: " + e.getLocalizedMessage());
511             }
512         }
513 
514         return false;
515     }
516 
517 
manageDocument(DocumentInfo doc)518     protected boolean manageDocument(DocumentInfo doc) {
519         if (isManagedDownload(doc)) {
520             // First try managing the document; we expect manager to filter
521             // based on authority, so we don't grant.
522             Intent manage = new Intent(DocumentsContract.ACTION_MANAGE_DOCUMENT);
523             manage.setData(doc.getDocumentUri());
524             try {
525                 doc.userId.startActivityAsUser(mActivity, manage);
526                 return true;
527             } catch (ActivityNotFoundException ex) {
528                 // Fall back to regular handling.
529             }
530         }
531 
532         return false;
533     }
534 
isManagedDownload(DocumentInfo doc)535     private boolean isManagedDownload(DocumentInfo doc) {
536         // Anything on downloads goes through the back through downloads manager
537         // (that's the MANAGE_DOCUMENT bit).
538         // This is done for two reasons:
539         // 1) The file in question might be a failed/queued or otherwise have some
540         //    specialized download handling.
541         // 2) For APKs, the download manager will add on some important security stuff
542         //    like origin URL.
543         // 3) For partial files, the download manager will offer to restart/retry downloads.
544 
545         // All other files not on downloads, event APKs, would get no benefit from this
546         // treatment, thusly the "isDownloads" check.
547 
548         // Launch MANAGE_DOCUMENTS only for the root level files, so it's not called for
549         // files in archives or in child folders. Also, if the activity is already browsing
550         // a ZIP from downloads, then skip MANAGE_DOCUMENTS.
551         if (Intent.ACTION_VIEW.equals(mActivity.getIntent().getAction())
552                 && mState.stack.size() > 1) {
553             // viewing the contents of an archive.
554             return false;
555         }
556 
557         // management is only supported in Downloads root or downloaded files show in Recent root.
558         if (Providers.AUTHORITY_DOWNLOADS.equals(doc.authority)) {
559             // only on APKs or partial files.
560             return MimeTypes.isApkType(doc.mimeType) || doc.isPartial();
561         }
562 
563         return false;
564     }
565 
buildViewIntent(DocumentInfo doc)566     protected Intent buildViewIntent(DocumentInfo doc) {
567         Intent intent = new Intent(Intent.ACTION_VIEW);
568         intent.setDataAndType(doc.getDocumentUri(), doc.mimeType);
569 
570         // Downloads has traditionally added the WRITE permission
571         // in the TrampolineActivity. Since this behavior is long
572         // established, we set the same permission for non-managed files
573         // This ensures consistent behavior between the Downloads root
574         // and other roots.
575         int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_SINGLE_TOP;
576         if (doc.isWriteSupported()) {
577             flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
578         }
579         // On desktop users expect files to open in a new window.
580         if (isDesktopFileHandlingFlagEnabled()) {
581             // The combination of NEW_DOCUMENT and MULTIPLE_TASK allows multiple instances of the
582             // same activity to open in separate windows.
583             flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
584             // If the activity has documentLaunchMode="never", NEW_TASK forces the activity to still
585             // open in a new window.
586             flags |= Intent.FLAG_ACTIVITY_NEW_TASK;
587         }
588         intent.setFlags(flags);
589 
590         return intent;
591     }
592 
593     @Override
previewItem(ItemDetails<String> doc)594     public boolean previewItem(ItemDetails<String> doc) {
595         throw new UnsupportedOperationException("Can't handle preview.");
596     }
597 
openFolderInSearchResult(@ullable DocumentStack stack, DocumentInfo doc)598     private void openFolderInSearchResult(@Nullable DocumentStack stack, DocumentInfo doc) {
599         if (stack == null) {
600             mState.stack.popToRootDocument();
601 
602             // Update navigator to give horizontal breadcrumb a chance to update documents. It
603             // doesn't update its content if the size of document stack doesn't change.
604             // TODO: update breadcrumb to take range update.
605             mActivity.updateNavigator();
606 
607             mState.stack.push(doc);
608         } else {
609             if (!Objects.equals(mState.stack.getRoot(), stack.getRoot())) {
610                 // It is now possible when opening cross-profile folder.
611                 Log.w(TAG, "Provider returns " + stack.getRoot() + " rather than expected "
612                         + mState.stack.getRoot());
613             }
614 
615             final DocumentInfo top = stack.peek();
616             if (top.isArchive()) {
617                 // Swap the zip file in original provider and the one provided by ArchiveProvider.
618                 stack.pop();
619                 stack.push(mDocs.getArchiveDocument(top.derivedUri, top.userId));
620             }
621 
622             mState.stack.reset();
623             // Update navigator to give horizontal breadcrumb a chance to update documents. It
624             // doesn't update its content if the size of document stack doesn't change.
625             // TODO: update breadcrumb to take range update.
626             mActivity.updateNavigator();
627 
628             mState.stack.reset(stack);
629         }
630 
631         // Show an opening animation only if pressing "back" would get us back to the
632         // previous directory. Especially after opening a root document, pressing
633         // back, wouldn't go to the previous root, but close the activity.
634         final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1)
635                 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE;
636         mActivity.refreshCurrentRootAndDirectory(anim);
637     }
638 
openChildContainer(DocumentInfo doc)639     private void openChildContainer(DocumentInfo doc) {
640         DocumentInfo currentDoc = null;
641 
642         if (doc.isDirectory()) {
643             // Regular directory.
644             currentDoc = doc;
645         } else if (doc.isArchive()) {
646             // Archive.
647             currentDoc = mDocs.getArchiveDocument(doc.derivedUri, doc.userId);
648         }
649 
650         assert (currentDoc != null);
651         if (currentDoc.equals(mState.stack.peek())) {
652             Log.w(TAG, "This DocumentInfo is already in current DocumentsStack");
653             return;
654         }
655 
656         mActivity.notifyDirectoryNavigated(currentDoc.derivedUri);
657 
658         mState.stack.push(currentDoc);
659         // Show an opening animation only if pressing "back" would get us back to the
660         // previous directory. Especially after opening a root document, pressing
661         // back, wouldn't go to the previous root, but close the activity.
662         final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1)
663                 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE;
664         mActivity.refreshCurrentRootAndDirectory(anim);
665     }
666 
667     @Override
setDebugMode(boolean enabled)668     public void setDebugMode(boolean enabled) {
669         if (!mInjector.features.isDebugSupportEnabled()) {
670             return;
671         }
672 
673         mState.debugMode = enabled;
674         mInjector.features.forceFeature(R.bool.feature_command_interceptor, enabled);
675         mInjector.features.forceFeature(R.bool.feature_inspector, enabled);
676         mActivity.invalidateOptionsMenu();
677 
678         if (enabled) {
679             showDebugMessage();
680         } else {
681             mActivity.getWindow().setStatusBarColor(
682                     mActivity.getResources().getColor(R.color.app_background_color));
683         }
684     }
685 
686     @Override
showDebugMessage()687     public void showDebugMessage() {
688         assert (mInjector.features.isDebugSupportEnabled());
689 
690         int[] colors = mInjector.debugHelper.getNextColors();
691         Pair<String, Integer> messagePair = mInjector.debugHelper.getNextMessage();
692 
693         Snackbars.showCustomTextWithImage(mActivity, messagePair.first, messagePair.second);
694 
695         mActivity.getWindow().setStatusBarColor(colors[1]);
696     }
697 
698     @Override
switchLauncherIcon()699     public void switchLauncherIcon() {
700         PackageManager pm = mActivity.getPackageManager();
701         if (pm != null) {
702             final boolean enalbled = Shared.isLauncherEnabled(mActivity);
703             ComponentName component = new ComponentName(
704                     mActivity.getPackageName(), Shared.LAUNCHER_TARGET_CLASS);
705             pm.setComponentEnabledSetting(component, enalbled
706                             ? PackageManager.COMPONENT_ENABLED_STATE_DISABLED
707                             : PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
708                     PackageManager.DONT_KILL_APP);
709         }
710     }
711 
712     @Override
cutToClipboard()713     public void cutToClipboard() {
714         throw new UnsupportedOperationException("Cut not supported!");
715     }
716 
717     @Override
copyToClipboard()718     public void copyToClipboard() {
719         throw new UnsupportedOperationException("Copy not supported!");
720     }
721 
722     @Override
showDeleteDialog()723     public void showDeleteDialog() {
724         throw new UnsupportedOperationException("Delete not supported!");
725     }
726 
727     @Override
deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent)728     public void deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent) {
729         throw new UnsupportedOperationException("Delete not supported!");
730     }
731 
732     @Override
shareSelectedDocuments()733     public void shareSelectedDocuments() {
734         throw new UnsupportedOperationException("Share not supported!");
735     }
736 
loadDocument(Uri uri, UserId userId, LoadDocStackCallback callback)737     protected final void loadDocument(Uri uri, UserId userId, LoadDocStackCallback callback) {
738         new LoadDocStackTask(
739                 mActivity,
740                 mProviders,
741                 mDocs,
742                 userId,
743                 callback
744         ).executeOnExecutor(mExecutors.lookup(uri.getAuthority()), uri);
745     }
746 
747     @Override
loadRoot(Uri uri, UserId userId)748     public final void loadRoot(Uri uri, UserId userId) {
749         new LoadRootTask<>(mActivity, mProviders, uri, userId, this::onRootLoaded)
750                 .executeOnExecutor(mExecutors.lookup(uri.getAuthority()));
751     }
752 
753     @Override
loadCrossProfileRoot(RootInfo info, UserId selectedUser)754     public final void loadCrossProfileRoot(RootInfo info, UserId selectedUser) {
755         if (info.isRecents()) {
756             openRoot(mProviders.getRecentsRoot(selectedUser));
757             return;
758         }
759         new LoadRootTask<>(mActivity, mProviders, info.getUri(), selectedUser,
760                 new LoadCrossProfileRootCallback(info, selectedUser))
761                 .executeOnExecutor(mExecutors.lookup(info.getUri().getAuthority()));
762     }
763 
764     private class LoadCrossProfileRootCallback implements LoadRootTask.LoadRootCallback {
765         private final RootInfo mOriginalRoot;
766         private final UserId mSelectedUserId;
767 
LoadCrossProfileRootCallback(RootInfo rootInfo, UserId selectedUser)768         LoadCrossProfileRootCallback(RootInfo rootInfo, UserId selectedUser) {
769             mOriginalRoot = rootInfo;
770             mSelectedUserId = selectedUser;
771         }
772 
773         @Override
onRootLoaded(@ullable RootInfo root)774         public void onRootLoaded(@Nullable RootInfo root) {
775             if (root == null) {
776                 // There is no such root in the other profile. Maybe the provider is missing on
777                 // the other profile. Create a placeholder root and open it to show error message.
778                 root = RootInfo.copyRootInfo(mOriginalRoot);
779                 root.userId = mSelectedUserId;
780             }
781             openRoot(root);
782         }
783     }
784 
785     @Override
loadFirstRoot(Uri uri)786     public final void loadFirstRoot(Uri uri) {
787         new LoadFirstRootTask<>(mActivity, mProviders, uri, this::onRootLoaded)
788                 .executeOnExecutor(mExecutors.lookup(uri.getAuthority()));
789     }
790 
791     @Override
loadDocumentsForCurrentStack()792     public void loadDocumentsForCurrentStack() {
793         // mState.stack may be empty when we cannot load the root document.
794         // However, we still want to restart loader because we may need to perform search in a
795         // cross-profile scenario.
796         // For RecentsLoader and GlobalSearchLoader, they do not require rootDoc so it is no-op.
797         // For DirectoryLoader, the loader needs to handle the case when stack.peek() returns null.
798 
799         // Only allow restartLoader when the previous loader is finished or reset. Allowing
800         // multiple consecutive calls to restartLoader() / onCreateLoader() will probably create
801         // multiple active loaders, because restartLoader() does not interrupt previous loaders'
802         // loading, therefore may block the UI thread and cause ANR.
803         if (mLoaderSemaphore.tryAcquire()) {
804             mActivity.getSupportLoaderManager().restartLoader(LOADER_ID, null, mBindings);
805         }
806     }
807 
launchToDocument(Uri uri)808     protected final boolean launchToDocument(Uri uri) {
809         if (DEBUG) {
810             Log.d(TAG, "launchToDocument() uri=" + uri);
811         }
812 
813         // We don't support launching to a document in an archive.
814         if (Providers.isArchiveUri(uri)) {
815             return false;
816         }
817 
818         loadDocument(uri, UserId.DEFAULT_USER, this::onStackToLaunchToLoaded);
819         return true;
820     }
821 
822     /**
823      * Invoked <b>only</b> once, when the initial stack (that is the stack we are going to
824      * "launch to") is loaded.
825      *
826      * @see #launchToDocument(Uri)
827      */
onStackToLaunchToLoaded(@ullable DocumentStack stack)828     private void onStackToLaunchToLoaded(@Nullable DocumentStack stack) {
829         if (DEBUG) {
830             Log.d(TAG, "onLaunchStackLoaded() stack=" + stack);
831         }
832 
833         if (stack == null) {
834             Log.w(TAG, "Failed to launch into the given uri. Launch to default location.");
835             launchToDefaultLocation();
836 
837             Metrics.logLaunchAtLocation(mState, null);
838             return;
839         }
840 
841         // Make sure the document at the top of the stack is a directory (if it isn't - just pop
842         // one off).
843         if (!stack.peek().isDirectory()) {
844             stack.pop();
845         }
846 
847         mState.stack.reset(stack);
848         mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
849 
850         Metrics.logLaunchAtLocation(mState, stack.getRoot().getUri());
851     }
852 
onRootLoaded(@ullable RootInfo root)853     private void onRootLoaded(@Nullable RootInfo root) {
854         boolean invalidRootForAction =
855                 (root != null
856                         && !root.supportsChildren()
857                         && mState.action == State.ACTION_OPEN_TREE);
858 
859         if (invalidRootForAction) {
860             loadDeviceRoot();
861         } else if (root != null) {
862             mActivity.onRootPicked(root);
863         } else {
864             launchToDefaultLocation();
865         }
866     }
867 
launchToDefaultLocation()868     protected abstract void launchToDefaultLocation();
869 
restoreRootAndDirectory()870     protected void restoreRootAndDirectory() {
871         if (!mState.stack.getRoot().isRecents() && mState.stack.isEmpty()) {
872             mActivity.onRootPicked(mState.stack.getRoot());
873         } else {
874             mActivity.restoreRootAndDirectory();
875         }
876     }
877 
loadDeviceRoot()878     protected final void loadDeviceRoot() {
879         loadRoot(DocumentsContract.buildRootUri(Providers.AUTHORITY_STORAGE,
880                 Providers.ROOT_ID_DEVICE), UserId.DEFAULT_USER);
881     }
882 
loadHomeDir()883     protected final void loadHomeDir() {
884         loadRoot(Shared.getDefaultRootUri(mActivity), UserId.DEFAULT_USER);
885     }
886 
loadRecent()887     protected final void loadRecent() {
888         mState.stack.changeRoot(mProviders.getRecentsRoot(UserId.DEFAULT_USER));
889         mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
890     }
891 
getStableSelection()892     protected MutableSelection<String> getStableSelection() {
893         MutableSelection<String> selection = new MutableSelection<>();
894         mSelectionMgr.copySelection(selection);
895         return selection;
896     }
897 
898     @Override
reset(ContentLock reloadLock)899     public ActionHandler reset(ContentLock reloadLock) {
900         mContentLock = reloadLock;
901         mActivity.getLoaderManager().destroyLoader(LOADER_ID);
902         return this;
903     }
904 
905     private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> {
906 
907         private ExecutorService mExecutorService = null;
908         private static final long MAX_SEARCH_TIME_MS = 3000;
909         private static final int MAX_RESULTS = 500;
910 
911         @NonNull
912         @Override
onCreateLoader(int id, Bundle args)913         public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
914             // If document stack is not initialized, i.e. if the root is null, create "Recents" root
915             // with the selected user.
916             if (!mState.stack.isInitialized()) {
917                 mState.stack.changeRoot(mActivity.getCurrentRoot());
918             }
919 
920             if (isUseSearchV2FlagEnabled()) {
921                 return onCreateLoaderV2(id, args);
922             }
923             return onCreateLoaderV1(id, args);
924         }
925 
onCreateLoaderV1(int id, Bundle args)926         private Loader<DirectoryResult> onCreateLoaderV1(int id, Bundle args) {
927             Context context = mActivity;
928 
929             if (mState.stack.isRecents()) {
930                 final LockingContentObserver observer = new LockingContentObserver(
931                         mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack);
932                 MultiRootDocumentsLoader loader;
933 
934                 if (mSearchMgr.isSearching()) {
935                     if (DEBUG) {
936                         Log.d(TAG, "Creating new GlobalSearchLoader.");
937                     }
938                     loader = new GlobalSearchLoader(
939                             context,
940                             mProviders,
941                             mState,
942                             mExecutors,
943                             mInjector.fileTypeLookup,
944                             mSearchMgr.buildQueryArgs(),
945                             mState.stack.getRoot().userId);
946                 } else {
947                     if (DEBUG) {
948                         Log.d(TAG, "Creating new loader recents.");
949                     }
950                     loader = new RecentsLoader(
951                             context,
952                             mProviders,
953                             mState,
954                             mExecutors,
955                             mInjector.fileTypeLookup,
956                             mState.stack.getRoot().userId);
957                 }
958                 loader.setObserver(observer);
959                 return loader;
960             } else {
961                 // There maybe no root docInfo
962                 DocumentInfo rootDoc = mState.stack.peek();
963 
964                 String authority = rootDoc == null
965                         ? mState.stack.getRoot().authority
966                         : rootDoc.authority;
967                 String documentId = rootDoc == null
968                         ? mState.stack.getRoot().documentId
969                         : rootDoc.documentId;
970 
971                 Uri contentsUri = mSearchMgr.isSearching()
972                         ? DocumentsContract.buildSearchDocumentsUri(
973                         mState.stack.getRoot().authority,
974                         mState.stack.getRoot().rootId,
975                         mSearchMgr.getCurrentSearch())
976                         : DocumentsContract.buildChildDocumentsUri(
977                                 authority,
978                                 documentId);
979 
980                 final Bundle queryArgs = mSearchMgr.isSearching()
981                         ? mSearchMgr.buildQueryArgs()
982                         : null;
983 
984                 if (mInjector.config.managedModeEnabled(mState.stack)) {
985                     contentsUri = DocumentsContract.setManageMode(contentsUri);
986                 }
987 
988                 if (DEBUG) {
989                     Log.d(TAG,
990                             "Creating new directory loader for: "
991                                     + DocumentInfo.debugString(mState.stack.peek()));
992                 }
993 
994                 return new DirectoryLoader(
995                         mInjector.features,
996                         context,
997                         mState,
998                         contentsUri,
999                         mInjector.fileTypeLookup,
1000                         mContentLock,
1001                         queryArgs);
1002             }
1003         }
1004 
onCreateLoaderV2(int id, Bundle args)1005         private Loader<DirectoryResult> onCreateLoaderV2(int id, Bundle args) {
1006             if (mExecutorService == null) {
1007                 // TODO(b:388130971): Fine tune the size of the thread pool.
1008                 mExecutorService = Executors.newFixedThreadPool(
1009                         GlobalSearchLoader.MAX_OUTSTANDING_TASK);
1010             }
1011             DocumentStack stack = mState.stack;
1012             RootInfo root = stack.getRoot();
1013             List<UserId> userIdList = DocumentsApplication.getUserIdManager(mActivity).getUserIds();
1014 
1015             Duration lastModifiedDelta = stack.isRecents()
1016                     ? Duration.ofMillis(RecentsLoader.REJECT_OLDER_THAN)
1017                     : null;
1018             int maxResults = (root == null || root.isRecents())
1019                     ? RecentsLoader.MAX_DOCS_FROM_ROOT : MAX_RESULTS;
1020             QueryOptions options = new QueryOptions(
1021                     maxResults, lastModifiedDelta, Duration.ofMillis(MAX_SEARCH_TIME_MS),
1022                     mState.showHiddenFiles, mState.acceptMimes, mSearchMgr.buildQueryArgs());
1023 
1024             if (stack.isRecents() || mSearchMgr.isSearching()) {
1025                 Log.d(TAG, "Creating search loader V2");
1026                 // For search and recent we create an observer that restart the loader every time
1027                 // one of the searched content providers reports a change.
1028                 final LockingContentObserver observer = new LockingContentObserver(
1029                         mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack);
1030                 Collection<RootInfo> rootList = new ArrayList<>();
1031                 if (stack.isRecents()) {
1032                     // TODO(b:381346575): Pass roots based on user selection.
1033                     rootList.addAll(mProviders.getMatchingRootsBlocking(mState).stream().filter(
1034                             r -> r.supportsSearch() && r.authority != null
1035                                     && r.rootId != null).toList());
1036                 } else {
1037                     rootList.add(root);
1038                 }
1039                 return new SearchLoader(
1040                         mActivity,
1041                         userIdList,
1042                         mInjector.fileTypeLookup,
1043                         observer,
1044                         rootList,
1045                         mSearchMgr.getCurrentSearch(),
1046                         options,
1047                         mState.sortModel,
1048                         mExecutorService
1049                 );
1050             }
1051             Log.d(TAG, "Creating folder loader V2");
1052             // For folder scan we pass the content lock to the loader so that it can register
1053             // an a callback to its internal method that forces a reload of the folder, every
1054             // time the content provider reports a change.
1055             return new FolderLoader(
1056                     mActivity,
1057                     userIdList,
1058                     mInjector.fileTypeLookup,
1059                     mContentLock,
1060                     root,
1061                     stack.peek(),
1062                     options,
1063                     mState.sortModel
1064             );
1065 
1066         }
1067 
1068         @Override
onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result)1069         public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
1070             if (DEBUG) {
1071                 Log.d(TAG, "Loader has finished for: "
1072                         + DocumentInfo.debugString(mState.stack.peek()));
1073             }
1074             assert (result != null);
1075 
1076             mInjector.getModel().update(result);
1077             mLoaderSemaphore.release();
1078         }
1079 
1080         @Override
onLoaderReset(Loader<DirectoryResult> loader)1081         public void onLoaderReset(Loader<DirectoryResult> loader) {
1082             mLoaderSemaphore.release();
1083         }
1084     }
1085 
1086     /**
1087      * A class primarily for the support of isolating our tests
1088      * from our concrete activity implementations.
1089      */
1090     public interface CommonAddons {
restoreRootAndDirectory()1091         void restoreRootAndDirectory();
1092 
refreshCurrentRootAndDirectory(@nimationType int anim)1093         void refreshCurrentRootAndDirectory(@AnimationType int anim);
1094 
onRootPicked(RootInfo root)1095         void onRootPicked(RootInfo root);
1096 
1097         // TODO: Move this to PickAddons as multi-document picking is exclusive to that activity.
onDocumentsPicked(List<DocumentInfo> docs)1098         void onDocumentsPicked(List<DocumentInfo> docs);
1099 
onDocumentPicked(DocumentInfo doc)1100         void onDocumentPicked(DocumentInfo doc);
1101 
getCurrentRoot()1102         RootInfo getCurrentRoot();
1103 
getCurrentDirectory()1104         DocumentInfo getCurrentDirectory();
1105 
getSelectedUser()1106         UserId getSelectedUser();
1107 
1108         /**
1109          * Check whether current directory is root of recent.
1110          */
isInRecents()1111         boolean isInRecents();
1112 
setRootsDrawerOpen(boolean open)1113         void setRootsDrawerOpen(boolean open);
1114 
1115         /**
1116          * Set the locked status of the DrawerController.
1117          */
setRootsDrawerLocked(boolean locked)1118         void setRootsDrawerLocked(boolean locked);
1119 
1120         // TODO: Let navigator listens to State
updateNavigator()1121         void updateNavigator();
1122 
1123         @VisibleForTesting
notifyDirectoryNavigated(Uri docUri)1124         void notifyDirectoryNavigated(Uri docUri);
1125     }
1126 }
1127