1 /*
2  * Copyright (C) 2015 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.Shared.DEBUG;
20 import static com.android.documentsui.base.Shared.EXTRA_BENCHMARK;
21 import static com.android.documentsui.base.State.MODE_GRID;
22 
23 import android.app.Activity;
24 import android.app.Fragment;
25 import android.content.Intent;
26 import android.content.pm.PackageInfo;
27 import android.content.pm.PackageManager;
28 import android.content.pm.ProviderInfo;
29 import android.net.Uri;
30 import android.os.Bundle;
31 import android.os.MessageQueue.IdleHandler;
32 import android.preference.PreferenceManager;
33 import android.provider.DocumentsContract;
34 import android.support.annotation.CallSuper;
35 import android.support.annotation.LayoutRes;
36 import android.support.annotation.VisibleForTesting;
37 import android.util.Log;
38 import android.view.KeyEvent;
39 import android.view.Menu;
40 import android.view.MenuItem;
41 import android.view.View;
42 import android.widget.Toolbar;
43 
44 import com.android.documentsui.AbstractActionHandler.CommonAddons;
45 import com.android.documentsui.Injector.Injected;
46 import com.android.documentsui.NavigationViewManager.Breadcrumb;
47 import com.android.documentsui.base.DocumentInfo;
48 import com.android.documentsui.base.RootInfo;
49 import com.android.documentsui.base.Shared;
50 import com.android.documentsui.base.State;
51 import com.android.documentsui.base.State.ViewMode;
52 import com.android.documentsui.dirlist.AnimationView;
53 import com.android.documentsui.dirlist.DirectoryFragment;
54 import com.android.documentsui.prefs.LocalPreferences;
55 import com.android.documentsui.prefs.Preferences;
56 import com.android.documentsui.prefs.PreferencesMonitor;
57 import com.android.documentsui.prefs.ScopedPreferences;
58 import com.android.documentsui.queries.CommandInterceptor;
59 import com.android.documentsui.queries.SearchViewManager;
60 import com.android.documentsui.queries.SearchViewManager.SearchManagerListener;
61 import com.android.documentsui.roots.ProvidersCache;
62 import com.android.documentsui.selection.Selection;
63 import com.android.documentsui.sidebar.RootsFragment;
64 import com.android.documentsui.sorting.SortController;
65 import com.android.documentsui.sorting.SortModel;
66 
67 import java.util.ArrayList;
68 import java.util.Date;
69 import java.util.List;
70 
71 import javax.annotation.Nullable;
72 
73 public abstract class BaseActivity
74         extends Activity implements CommonAddons, NavigationViewManager.Environment {
75 
76     private static final String BENCHMARK_TESTING_PACKAGE = "com.android.documentsui.appperftests";
77 
78     protected SearchViewManager mSearchManager;
79     protected State mState;
80 
81     @Injected
82     protected Injector<?> mInjector;
83 
84     protected @Nullable RetainedState mRetainedState;
85     protected ProvidersCache mProviders;
86     protected DocumentsAccess mDocs;
87     protected DrawerController mDrawer;
88 
89     protected NavigationViewManager mNavigator;
90     protected SortController mSortController;
91 
92     private final List<EventListener> mEventListeners = new ArrayList<>();
93     private final String mTag;
94 
95     @LayoutRes
96     private int mLayoutId;
97 
98     private RootsMonitor<BaseActivity> mRootsMonitor;
99 
100     private long mStartTime;
101 
102     private PreferencesMonitor mPreferencesMonitor;
103 
BaseActivity(@ayoutRes int layoutId, String tag)104     public BaseActivity(@LayoutRes int layoutId, String tag) {
105         mLayoutId = layoutId;
106         mTag = tag;
107     }
108 
refreshDirectory(int anim)109     protected abstract void refreshDirectory(int anim);
110     /** Allows sub-classes to include information in a newly created State instance. */
includeState(State initialState)111     protected abstract void includeState(State initialState);
onDirectoryCreated(DocumentInfo doc)112     protected abstract void onDirectoryCreated(DocumentInfo doc);
113 
getInjector()114     public abstract Injector<?> getInjector();
115 
116     @CallSuper
117     @Override
onCreate(Bundle icicle)118     public void onCreate(Bundle icicle) {
119         // Record the time when onCreate is invoked for metric.
120         mStartTime = new Date().getTime();
121 
122         super.onCreate(icicle);
123 
124         final Intent intent = getIntent();
125 
126         addListenerForLaunchCompletion();
127 
128         setContentView(mLayoutId);
129 
130         mInjector = getInjector();
131         mState = getState(icicle);
132         mDrawer = DrawerController.create(this, mInjector.config);
133         Metrics.logActivityLaunch(this, mState, intent);
134 
135         // we're really interested in retainining state in our very complex
136         // DirectoryFragment. So we do a little code yoga to extend
137         // support to that fragment.
138         mRetainedState = (RetainedState) getLastNonConfigurationInstance();
139         mProviders = DocumentsApplication.getProvidersCache(this);
140         mDocs = DocumentsAccess.create(this);
141 
142         Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
143         setActionBar(toolbar);
144 
145         Breadcrumb breadcrumb =
146                 Shared.findView(this, R.id.dropdown_breadcrumb, R.id.horizontal_breadcrumb);
147         assert(breadcrumb != null);
148 
149         mNavigator = new NavigationViewManager(mDrawer, toolbar, mState, this, breadcrumb);
150         SearchManagerListener searchListener = new SearchManagerListener() {
151             /**
152              * Called when search results changed. Refreshes the content of the directory. It
153              * doesn't refresh elements on the action bar. e.g. The current directory name displayed
154              * on the action bar won't get updated.
155              */
156             @Override
157             public void onSearchChanged(@Nullable String query) {
158                 if (query != null) {
159                     Metrics.logUserAction(BaseActivity.this, Metrics.USER_ACTION_SEARCH);
160                 }
161 
162                 mInjector.actions.loadDocumentsForCurrentStack();
163             }
164 
165             @Override
166             public void onSearchFinished() {
167                 // Restores menu icons state
168                 invalidateOptionsMenu();
169             }
170 
171             @Override
172             public void onSearchViewChanged(boolean opened) {
173                 mNavigator.update();
174             }
175         };
176 
177         // "Commands" are meta input for controlling system behavior.
178         // We piggy back on search input as it is the only text input
179         // area in the app. But the functionality is independent
180         // of "regular" search query processing.
181         CommandInterceptor dbgCommands = new CommandInterceptor(mInjector.features);
182         dbgCommands.add(new CommandInterceptor.DumpRootsCacheHandler(this));
183         mSearchManager = new SearchViewManager(searchListener, dbgCommands, icicle);
184         mSortController = SortController.create(this, mState.derivedMode, mState.sortModel);
185 
186         mPreferencesMonitor = new PreferencesMonitor(
187                 getApplicationContext().getPackageName(),
188                 PreferenceManager.getDefaultSharedPreferences(this),
189                 this::onPreferenceChanged);
190         mPreferencesMonitor.start();
191 
192         // Base classes must update result in their onCreate.
193         setResult(Activity.RESULT_CANCELED);
194     }
195 
onPreferenceChanged(String pref)196     public void onPreferenceChanged(String pref) {
197         // For now, we only work with prefs that we backup. This
198         // just limits the scope of what we expect to come flowing
199         // through here until we know we want more and fancier options.
200         assert(Preferences.shouldBackup(pref));
201 
202         switch (pref) {
203             case ScopedPreferences.INCLUDE_DEVICE_ROOT:
204                 updateDisplayAdvancedDevices(mInjector.prefs.getShowDeviceRoot());
205         }
206     }
207 
208     @Override
onPostCreate(Bundle savedInstanceState)209     protected void onPostCreate(Bundle savedInstanceState) {
210         super.onPostCreate(savedInstanceState);
211 
212         mRootsMonitor = new RootsMonitor<>(
213                 this,
214                 mInjector.actions,
215                 mProviders,
216                 mDocs,
217                 mState,
218                 mSearchManager,
219                 mInjector.actionModeController::finishActionMode);
220         mRootsMonitor.start();
221     }
222 
223     @Override
onCreateOptionsMenu(Menu menu)224     public boolean onCreateOptionsMenu(Menu menu) {
225         boolean showMenu = super.onCreateOptionsMenu(menu);
226 
227         getMenuInflater().inflate(R.menu.activity, menu);
228         mNavigator.update();
229         boolean fullBarSearch = getResources().getBoolean(R.bool.full_bar_search_view);
230         mSearchManager.install(menu, fullBarSearch);
231 
232         return showMenu;
233     }
234 
235     @Override
236     @CallSuper
onPrepareOptionsMenu(Menu menu)237     public boolean onPrepareOptionsMenu(Menu menu) {
238         super.onPrepareOptionsMenu(menu);
239         mSearchManager.showMenu(mState.stack);
240         return true;
241     }
242 
243     @Override
onDestroy()244     protected void onDestroy() {
245         mRootsMonitor.stop();
246         mPreferencesMonitor.stop();
247         mSortController.destroy();
248         super.onDestroy();
249     }
250 
getState(@ullable Bundle icicle)251     private State getState(@Nullable Bundle icicle) {
252         if (icicle != null) {
253             State state = icicle.<State>getParcelable(Shared.EXTRA_STATE);
254             if (DEBUG) Log.d(mTag, "Recovered existing state object: " + state);
255             return state;
256         }
257 
258         State state = new State();
259 
260         final Intent intent = getIntent();
261 
262         state.sortModel = SortModel.createModel();
263         state.localOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false);
264         state.excludedAuthorities = getExcludedAuthorities();
265 
266         includeState(state);
267 
268         state.showAdvanced = Shared.mustShowDeviceRoot(intent)
269                 || mInjector.prefs.getShowDeviceRoot();
270 
271         // Only show the toggle if advanced isn't forced enabled.
272         state.showDeviceStorageOption = !Shared.mustShowDeviceRoot(intent);
273 
274         if (DEBUG) Log.d(mTag, "Created new state object: " + state);
275 
276         return state;
277     }
278 
279     @Override
setRootsDrawerOpen(boolean open)280     public void setRootsDrawerOpen(boolean open) {
281         mNavigator.revealRootsDrawer(open);
282     }
283 
284     @Override
onRootPicked(RootInfo root)285     public void onRootPicked(RootInfo root) {
286         // Clicking on the current root removes search
287         mSearchManager.cancelSearch();
288 
289         // Skip refreshing if root nor directory didn't change
290         if (root.equals(getCurrentRoot()) && mState.stack.size() == 1) {
291             return;
292         }
293 
294         mInjector.actionModeController.finishActionMode();
295         mState.derivedMode = LocalPreferences.getViewMode(this, root, MODE_GRID);
296         mSortController.onViewModeChanged(mState.derivedMode);
297 
298         // Set summary header's visibility. Only recents and downloads root may have summary in
299         // their docs.
300         mState.sortModel.setDimensionVisibility(
301                 SortModel.SORT_DIMENSION_ID_SUMMARY,
302                 root.isRecents() || root.isDownloads() ? View.VISIBLE : View.INVISIBLE);
303 
304         // Clear entire backstack and start in new root
305         mState.stack.changeRoot(root);
306 
307         // Recents is always in memory, so we just load it directly.
308         // Otherwise we delegate loading data from disk to a task
309         // to ensure a responsive ui.
310         if (mProviders.isRecentsRoot(root)) {
311             refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
312         } else {
313             mInjector.actions.getRootDocument(
314                     root,
315                     TimeoutTask.DEFAULT_TIMEOUT,
316                     doc -> mInjector.actions.openRootDocument(doc));
317         }
318     }
319 
320     @Override
onOptionsItemSelected(MenuItem item)321     public boolean onOptionsItemSelected(MenuItem item) {
322 
323         switch (item.getItemId()) {
324             case android.R.id.home:
325                 onBackPressed();
326                 return true;
327 
328             case R.id.menu_create_dir:
329                 showCreateDirectoryDialog();
330                 return true;
331 
332             case R.id.menu_search:
333                 // SearchViewManager listens for this directly.
334                 return false;
335 
336             case R.id.menu_grid:
337                 setViewMode(State.MODE_GRID);
338                 return true;
339 
340             case R.id.menu_list:
341                 setViewMode(State.MODE_LIST);
342                 return true;
343 
344             case R.id.menu_advanced:
345                 onDisplayAdvancedDevices();
346                 return true;
347 
348             case R.id.menu_select_all:
349                 getInjector().actions.selectAllFiles();
350                 return true;
351 
352             case R.id.menu_debug:
353                 getInjector().actions.showDebugMessage();
354                 return true;
355 
356             default:
357                 return super.onOptionsItemSelected(item);
358         }
359     }
360 
getDirectoryFragment()361     protected final @Nullable DirectoryFragment getDirectoryFragment() {
362         return DirectoryFragment.get(getFragmentManager());
363     }
364 
showCreateDirectoryDialog()365     protected void showCreateDirectoryDialog() {
366         Metrics.logUserAction(this, Metrics.USER_ACTION_CREATE_DIR);
367 
368         CreateDirectoryFragment.show(getFragmentManager());
369     }
370 
371     /**
372      * Returns true if a directory can be created in the current location.
373      * @return
374      */
canCreateDirectory()375     protected boolean canCreateDirectory() {
376         final RootInfo root = getCurrentRoot();
377         final DocumentInfo cwd = getCurrentDirectory();
378         return cwd != null
379                 && cwd.isCreateSupported()
380                 && !mSearchManager.isSearching()
381                 && !root.isRecents();
382     }
383 
384     // TODO: make navigator listen to state
385     @Override
updateNavigator()386     public final void updateNavigator() {
387         mNavigator.update();
388     }
389 
390     /**
391      * Refreshes the content of the director and the menu/action bar.
392      * The current directory name and selection will get updated.
393      * @param anim
394      */
395     @Override
refreshCurrentRootAndDirectory(int anim)396     public final void refreshCurrentRootAndDirectory(int anim) {
397         // The following call will crash if it's called before onCreateOptionMenu() is called in
398         // which we install menu item to search view manager, and there is a search query we need to
399         // restore. This happens when we're still initializing our UI so we shouldn't cancel the
400         // search which will be restored later in onCreateOptionMenu(). Try finding a way to guard
401         // refreshCurrentRootAndDirectory() from being called while we're restoring the state of UI
402         // from the saved state passed in onCreate().
403         mSearchManager.cancelSearch();
404 
405         refreshDirectory(anim);
406 
407         final RootsFragment roots = RootsFragment.get(getFragmentManager());
408         if (roots != null) {
409             roots.onCurrentRootChanged();
410         }
411 
412         mNavigator.update();
413         // Causes talkback to announce the activity's new title
414         if (mState.stack.isRecents()) {
415             setTitle(mProviders.getRecentsRoot().title);
416         } else {
417             setTitle(mState.stack.getTitle());
418         }
419         invalidateOptionsMenu();
420     }
421 
getExcludedAuthorities()422     private final List<String> getExcludedAuthorities() {
423         List<String> authorities = new ArrayList<>();
424         if (getIntent().getBooleanExtra(DocumentsContract.EXTRA_EXCLUDE_SELF, false)) {
425             // Exclude roots provided by the calling package.
426             String packageName = Shared.getCallingPackageName(this);
427             try {
428                 PackageInfo pkgInfo = getPackageManager().getPackageInfo(packageName,
429                         PackageManager.GET_PROVIDERS);
430                 for (ProviderInfo provider: pkgInfo.providers) {
431                     authorities.add(provider.authority);
432                 }
433             } catch (PackageManager.NameNotFoundException e) {
434                 Log.e(mTag, "Calling package name does not resolve: " + packageName);
435             }
436         }
437         return authorities;
438     }
439 
get(Fragment fragment)440     public static BaseActivity get(Fragment fragment) {
441         return (BaseActivity) fragment.getActivity();
442     }
443 
getDisplayState()444     public State getDisplayState() {
445         return mState;
446     }
447 
getShadowBuilder()448     public DragShadowBuilder getShadowBuilder() {
449         throw new UnsupportedOperationException(
450                 "Drag and drop not supported, can't get shadow builder");
451     }
452 
453     /**
454      * Set internal storage visible based on explicit user action.
455      */
onDisplayAdvancedDevices()456     private void onDisplayAdvancedDevices() {
457         boolean display = !mState.showAdvanced;
458         Metrics.logUserAction(this,
459                 display ? Metrics.USER_ACTION_SHOW_ADVANCED : Metrics.USER_ACTION_HIDE_ADVANCED);
460 
461         mInjector.prefs.setShowDeviceRoot(display);
462         updateDisplayAdvancedDevices(display);
463     }
464 
updateDisplayAdvancedDevices(boolean display)465     private void updateDisplayAdvancedDevices(boolean display) {
466         mState.showAdvanced = display;
467         @Nullable RootsFragment fragment = RootsFragment.get(getFragmentManager());
468         if (fragment != null) {
469             fragment.onDisplayStateChanged();
470         }
471         invalidateOptionsMenu();
472     }
473 
474     /**
475      * Set mode based on explicit user action.
476      */
setViewMode(@iewMode int mode)477     void setViewMode(@ViewMode int mode) {
478         if (mode == State.MODE_GRID) {
479             Metrics.logUserAction(this, Metrics.USER_ACTION_GRID);
480         } else if (mode == State.MODE_LIST) {
481             Metrics.logUserAction(this, Metrics.USER_ACTION_LIST);
482         }
483 
484         LocalPreferences.setViewMode(this, getCurrentRoot(), mode);
485         mState.derivedMode = mode;
486 
487         // view icon needs to be updated, but we *could* do it
488         // in onOptionsItemSelected, and not do the full invalidation
489         // But! That's a larger refactoring we'll save for another day.
490         invalidateOptionsMenu();
491         DirectoryFragment dir = getDirectoryFragment();
492         if (dir != null) {
493             dir.onViewModeChanged();
494         }
495 
496         mSortController.onViewModeChanged(mode);
497     }
498 
setPending(boolean pending)499     public void setPending(boolean pending) {
500         // TODO: Isolate this behavior to PickActivity.
501     }
502 
503     @Override
onSaveInstanceState(Bundle state)504     protected void onSaveInstanceState(Bundle state) {
505         super.onSaveInstanceState(state);
506         state.putParcelable(Shared.EXTRA_STATE, mState);
507         mSearchManager.onSaveInstanceState(state);
508     }
509 
510     @Override
onRestoreInstanceState(Bundle state)511     protected void onRestoreInstanceState(Bundle state) {
512         super.onRestoreInstanceState(state);
513     }
514 
515     /**
516      * Delegate ths call to the current fragment so it can save selection.
517      * Feel free to expand on this with other useful state.
518      */
519     @Override
onRetainNonConfigurationInstance()520     public RetainedState onRetainNonConfigurationInstance() {
521         RetainedState retained = new RetainedState();
522         DirectoryFragment fragment = DirectoryFragment.get(getFragmentManager());
523         if (fragment != null) {
524             fragment.retainState(retained);
525         }
526         return retained;
527     }
528 
getRetainedState()529     public @Nullable RetainedState getRetainedState() {
530         return mRetainedState;
531     }
532 
533     @Override
isSearchExpanded()534     public boolean isSearchExpanded() {
535         return mSearchManager.isExpanded();
536     }
537 
538     @Override
getCurrentRoot()539     public RootInfo getCurrentRoot() {
540         RootInfo root = mState.stack.getRoot();
541         if (root != null) {
542             return root;
543         } else {
544             return mProviders.getRecentsRoot();
545         }
546     }
547 
548     @Override
getCurrentDirectory()549     public DocumentInfo getCurrentDirectory() {
550         return mState.stack.peek();
551     }
552 
553     @VisibleForTesting
addEventListener(EventListener listener)554     public void addEventListener(EventListener listener) {
555         mEventListeners.add(listener);
556     }
557 
558     @VisibleForTesting
removeEventListener(EventListener listener)559     public void removeEventListener(EventListener listener) {
560         mEventListeners.remove(listener);
561     }
562 
563     @VisibleForTesting
notifyDirectoryLoaded(Uri uri)564     public void notifyDirectoryLoaded(Uri uri) {
565         for (EventListener listener : mEventListeners) {
566             listener.onDirectoryLoaded(uri);
567         }
568     }
569 
570     @VisibleForTesting
571     @Override
notifyDirectoryNavigated(Uri uri)572     public void notifyDirectoryNavigated(Uri uri) {
573         for (EventListener listener : mEventListeners) {
574             listener.onDirectoryNavigated(uri);
575         }
576     }
577 
578     @Override
dispatchKeyEvent(KeyEvent event)579     public boolean dispatchKeyEvent(KeyEvent event) {
580         if (event.getAction() == KeyEvent.ACTION_DOWN) {
581             mInjector.debugHelper.debugCheck(event.getDownTime(), event.getKeyCode());
582         }
583         return super.dispatchKeyEvent(event);
584     }
585 
586     @Override
onActivityResult(int requestCode, int resultCode, Intent data)587     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
588         mInjector.actions.onActivityResult(requestCode, resultCode, data);
589     }
590 
591     /**
592      * Pops the top entry off the directory stack, and returns the user to the previous directory.
593      * If the directory stack only contains one item, this method does nothing.
594      *
595      * @return Whether the stack was popped.
596      */
popDir()597     protected boolean popDir() {
598         if (mState.stack.size() > 1) {
599             mState.stack.pop();
600             refreshCurrentRootAndDirectory(AnimationView.ANIM_LEAVE);
601             return true;
602         }
603         return false;
604     }
605 
focusSidebar()606     protected boolean focusSidebar() {
607         RootsFragment rf = RootsFragment.get(getFragmentManager());
608         assert (rf != null);
609         return rf.requestFocus();
610     }
611 
612     /**
613      * Closes the activity when it's idle.
614      */
addListenerForLaunchCompletion()615     private void addListenerForLaunchCompletion() {
616         addEventListener(new EventListener() {
617             @Override
618             public void onDirectoryNavigated(Uri uri) {
619             }
620 
621             @Override
622             public void onDirectoryLoaded(Uri uri) {
623                 removeEventListener(this);
624                 getMainLooper().getQueue().addIdleHandler(new IdleHandler() {
625                     @Override
626                     public boolean queueIdle() {
627                         // If startup benchmark is requested by a whitelisted testing package, then
628                         // close the activity once idle, and notify the testing activity.
629                         if (getIntent().getBooleanExtra(EXTRA_BENCHMARK, false) &&
630                                 BENCHMARK_TESTING_PACKAGE.equals(getCallingPackage())) {
631                             setResult(RESULT_OK);
632                             finish();
633                         }
634 
635                         Metrics.logStartupMs(
636                                 BaseActivity.this, (int) (new Date().getTime() - mStartTime));
637 
638                         // Remove the idle handler.
639                         return false;
640                     }
641                 });
642             }
643         });
644     }
645 
646     public static final class RetainedState {
647         public @Nullable Selection selection;
648 
hasSelection()649         public boolean hasSelection() {
650             return selection != null;
651         }
652     }
653 
654     @VisibleForTesting
655     protected interface EventListener {
656         /**
657          * @param uri Uri navigated to. If recents, then null.
658          */
onDirectoryNavigated(@ullable Uri uri)659         void onDirectoryNavigated(@Nullable Uri uri);
660 
661         /**
662          * @param uri Uri of the loaded directory. If recents, then null.
663          */
onDirectoryLoaded(@ullable Uri uri)664         void onDirectoryLoaded(@Nullable Uri uri);
665     }
666 }
667