/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui; import static com.android.documentsui.base.Shared.EXTRA_BENCHMARK; import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.State.MODE_GRID; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.ProviderInfo; import android.graphics.Color; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.MessageQueue.IdleHandler; import android.preference.PreferenceManager; import android.provider.DocumentsContract; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Checkable; import android.widget.TextView; import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.ActionMenuView; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import com.android.documentsui.AbstractActionHandler.CommonAddons; import com.android.documentsui.Injector.Injected; import com.android.documentsui.NavigationViewManager.Breadcrumb; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.EventHandler; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.Shared; import com.android.documentsui.base.State; import com.android.documentsui.base.State.ViewMode; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.AppsRowManager; import com.android.documentsui.dirlist.DirectoryFragment; import com.android.documentsui.prefs.LocalPreferences; import com.android.documentsui.prefs.Preferences; import com.android.documentsui.prefs.PreferencesMonitor; import com.android.documentsui.prefs.ScopedPreferences; import com.android.documentsui.queries.CommandInterceptor; import com.android.documentsui.queries.SearchChipData; import com.android.documentsui.queries.SearchFragment; import com.android.documentsui.queries.SearchViewManager; import com.android.documentsui.queries.SearchViewManager.SearchManagerListener; import com.android.documentsui.roots.ProvidersCache; import com.android.documentsui.sidebar.RootsFragment; import com.android.documentsui.sorting.SortController; import com.android.documentsui.sorting.SortModel; import com.google.android.material.appbar.AppBarLayout; import java.util.ArrayList; import java.util.Date; import java.util.List; import javax.annotation.Nullable; public abstract class BaseActivity extends AppCompatActivity implements CommonAddons, NavigationViewManager.Environment { private static final String BENCHMARK_TESTING_PACKAGE = "com.android.documentsui.appperftests"; protected SearchViewManager mSearchManager; protected AppsRowManager mAppsRowManager; protected State mState; @Injected protected Injector mInjector; protected ProvidersCache mProviders; protected DocumentsAccess mDocs; protected DrawerController mDrawer; protected NavigationViewManager mNavigator; protected SortController mSortController; private final List mEventListeners = new ArrayList<>(); private final String mTag; @LayoutRes private int mLayoutId; private RootsMonitor mRootsMonitor; private long mStartTime; private boolean mHasQueryContentFromIntent; private PreferencesMonitor mPreferencesMonitor; public BaseActivity(@LayoutRes int layoutId, String tag) { mLayoutId = layoutId; mTag = tag; } protected abstract void refreshDirectory(int anim); /** Allows sub-classes to include information in a newly created State instance. */ protected abstract void includeState(State initialState); protected abstract void onDirectoryCreated(DocumentInfo doc); public abstract Injector getInjector(); @CallSuper @Override public void onCreate(Bundle icicle) { // Record the time when onCreate is invoked for metric. mStartTime = new Date().getTime(); // ToDo Create tool to check resource version before applyStyle for the theme // If version code is not match, we should reset overlay package to default, // in case Activity continueusly encounter resource not found exception getTheme().applyStyle(R.style.DocumentsDefaultTheme, false); super.onCreate(icicle); final Intent intent = getIntent(); addListenerForLaunchCompletion(); setContentView(mLayoutId); setContainer(); mInjector = getInjector(); mState = getState(icicle); mDrawer = DrawerController.create(this, mInjector.config); Metrics.logActivityLaunch(mState, intent); mProviders = DocumentsApplication.getProvidersCache(this); mDocs = DocumentsAccess.create(this); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); Breadcrumb breadcrumb = Shared.findView(this, R.id.dropdown_breadcrumb, R.id.horizontal_breadcrumb); assert(breadcrumb != null); mNavigator = new NavigationViewManager(this, mDrawer, mState, this, breadcrumb); SearchManagerListener searchListener = new SearchManagerListener() { /** * Called when search results changed. Refreshes the content of the directory. It * doesn't refresh elements on the action bar. e.g. The current directory name displayed * on the action bar won't get updated. */ @Override public void onSearchChanged(@Nullable String query) { if (mSearchManager.isSearching()) { Metrics.logSearchMode(query != null, mSearchManager.hasCheckedChip()); if (mInjector.pickResult != null) { mInjector.pickResult.increaseActionCount(); } } mInjector.actions.loadDocumentsForCurrentStack(); expandAppBar(); DirectoryFragment dir = getDirectoryFragment(); if (dir != null) { dir.scrollToTop(); } } @Override public void onSearchFinished() { // Restores menu icons state invalidateOptionsMenu(); } @Override public void onSearchViewChanged(boolean opened) { mNavigator.update(); } @Override public void onSearchChipStateChanged(View v) { final Checkable chip = (Checkable) v; if (chip.isChecked()) { final SearchChipData item = (SearchChipData) v.getTag(); Metrics.logUserAction(MetricConsts.USER_ACTION_SEARCH_CHIP); Metrics.logSearchType(item.getChipType()); } } @Override public void onSearchViewFocusChanged(boolean hasFocus) { final boolean isInitailSearch = !TextUtils.isEmpty(mSearchManager.getCurrentSearch()) && TextUtils.isEmpty(mSearchManager.getSearchViewText()); if (hasFocus && (SearchFragment.get(getSupportFragmentManager()) == null) && !isInitailSearch) { SearchFragment.showFragment(getSupportFragmentManager(), mSearchManager.getSearchViewText()); } } @Override public void onSearchViewClearClicked() { if (SearchFragment.get(getSupportFragmentManager()) == null) { SearchFragment.showFragment(getSupportFragmentManager(), mSearchManager.getSearchViewText()); } } }; // "Commands" are meta input for controlling system behavior. // We piggy back on search input as it is the only text input // area in the app. But the functionality is independent // of "regular" search query processing. final CommandInterceptor cmdInterceptor = new CommandInterceptor(mInjector.features); cmdInterceptor.add(new CommandInterceptor.DumpRootsCacheHandler(this)); // A tiny decorator that adds support for enabling CommandInterceptor // based on query input. It's sorta like CommandInterceptor, but its metaaahhh. EventHandler queryInterceptor = CommandInterceptor.createDebugModeFlipper( mInjector.features, mInjector.debugHelper::toggleDebugMode, cmdInterceptor); ViewGroup chipGroup = findViewById(R.id.search_chip_group); mSearchManager = new SearchViewManager(searchListener, queryInterceptor, chipGroup, icicle); // initialize the chip sets by accept mime types mSearchManager.initChipSets(mState.acceptMimes); // update the chip items by the mime types of the root mSearchManager.updateChips(getCurrentRoot().derivedMimeTypes); // parse the query content from intent when launch the // activity at the first time if (icicle == null) { mHasQueryContentFromIntent = mSearchManager.parseQueryContentFromIntent(getIntent(), mState.action); } mNavigator.setSearchBarClickListener(v -> { mSearchManager.onSearchBarClicked(); mNavigator.update(); }); mSortController = SortController.create(this, mState.derivedMode, mState.sortModel); mPreferencesMonitor = new PreferencesMonitor( getApplicationContext().getPackageName(), PreferenceManager.getDefaultSharedPreferences(this), this::onPreferenceChanged); mPreferencesMonitor.start(); // Base classes must update result in their onCreate. setResult(AppCompatActivity.RESULT_CANCELED); } public void onPreferenceChanged(String pref) { // For now, we only work with prefs that we backup. This // just limits the scope of what we expect to come flowing // through here until we know we want more and fancier options. assert(Preferences.shouldBackup(pref)); switch (pref) { case ScopedPreferences.INCLUDE_DEVICE_ROOT: updateDisplayAdvancedDevices(mInjector.prefs.getShowDeviceRoot()); } } @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); mRootsMonitor = new RootsMonitor<>( this, mInjector.actions, mProviders, mDocs, mState, mSearchManager, mInjector.actionModeController::finishActionMode); mRootsMonitor.start(); } @Override public boolean onCreateOptionsMenu(Menu menu) { boolean showMenu = super.onCreateOptionsMenu(menu); getMenuInflater().inflate(R.menu.activity, menu); mNavigator.update(); boolean fullBarSearch = getResources().getBoolean(R.bool.full_bar_search_view); boolean showSearchBar = getResources().getBoolean(R.bool.show_search_bar); mSearchManager.install(menu, fullBarSearch, showSearchBar); final ActionMenuView subMenuView = findViewById(R.id.sub_menu); // If size is 0, it means the menu has not inflated and it should only do once. if (subMenuView.getMenu().size() == 0) { subMenuView.setOnMenuItemClickListener(this::onOptionsItemSelected); getMenuInflater().inflate(R.menu.sub_menu, subMenuView.getMenu()); } return showMenu; } @Override @CallSuper public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); mSearchManager.showMenu(mState.stack); final ActionMenuView subMenuView = findViewById(R.id.sub_menu); mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); return true; } @Override protected void onDestroy() { mRootsMonitor.stop(); mPreferencesMonitor.stop(); mSortController.destroy(); super.onDestroy(); } private State getState(@Nullable Bundle icicle) { if (icicle != null) { State state = icicle.getParcelable(Shared.EXTRA_STATE); if (DEBUG) { Log.d(mTag, "Recovered existing state object: " + state); } return state; } State state = new State(); final Intent intent = getIntent(); state.sortModel = SortModel.createModel(); state.localOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false); state.excludedAuthorities = getExcludedAuthorities(); includeState(state); state.showAdvanced = Shared.mustShowDeviceRoot(intent) || mInjector.prefs.getShowDeviceRoot(); // Only show the toggle if advanced isn't forced enabled. state.showDeviceStorageOption = !Shared.mustShowDeviceRoot(intent); if (DEBUG) { Log.d(mTag, "Created new state object: " + state); } return state; } private void setContainer() { View root = findViewById(R.id.coordinator_layout); root.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); root.setOnApplyWindowInsetsListener((v, insets) -> { root.setPadding(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); View saveContainer = findViewById(R.id.container_save); saveContainer.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom()); View rootsContainer = findViewById(R.id.container_roots); rootsContainer.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom()); return insets.consumeSystemWindowInsets(); }); getWindow().setNavigationBarDividerColor(Color.TRANSPARENT); if (Build.VERSION.SDK_INT >= 29) { getWindow().setNavigationBarColor(Color.TRANSPARENT); getWindow().setNavigationBarContrastEnforced(true); } else { getWindow().setNavigationBarColor(getColor(R.color.nav_bar_translucent)); } } @Override public void setRootsDrawerOpen(boolean open) { mNavigator.revealRootsDrawer(open); } @Override public void onRootPicked(RootInfo root) { // Clicking on the current root removes search mSearchManager.cancelSearch(); // Skip refreshing if root nor directory didn't change if (root.equals(getCurrentRoot()) && mState.stack.size() == 1) { return; } mInjector.actionModeController.finishActionMode(); mSortController.onViewModeChanged(mState.derivedMode); // Set summary header's visibility. Only recents and downloads root may have summary in // their docs. mState.sortModel.setDimensionVisibility( SortModel.SORT_DIMENSION_ID_SUMMARY, root.isRecents() || root.isDownloads() ? View.VISIBLE : View.INVISIBLE); // Clear entire backstack and start in new root mState.stack.changeRoot(root); // Recents is always in memory, so we just load it directly. // Otherwise we delegate loading data from disk to a task // to ensure a responsive ui. if (mProviders.isRecentsRoot(root)) { refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); } else { mInjector.actions.getRootDocument( root, TimeoutTask.DEFAULT_TIMEOUT, doc -> mInjector.actions.openRootDocument(doc)); } expandAppBar(); updateHeaderTitle(); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: onBackPressed(); return true; case R.id.option_menu_create_dir: getInjector().actions.showCreateDirectoryDialog(); return true; case R.id.option_menu_search: // SearchViewManager listens for this directly. return false; case R.id.option_menu_advanced: onDisplayAdvancedDevices(); return true; case R.id.option_menu_select_all: getInjector().actions.selectAllFiles(); return true; case R.id.option_menu_debug: getInjector().actions.showDebugMessage(); return true; case R.id.option_menu_sort: getInjector().actions.showSortDialog(); return true; case R.id.sub_menu_grid: setViewMode(State.MODE_GRID); return true; case R.id.sub_menu_list: setViewMode(State.MODE_LIST); return true; default: return super.onOptionsItemSelected(item); } } protected final @Nullable DirectoryFragment getDirectoryFragment() { return DirectoryFragment.get(getSupportFragmentManager()); } /** * Returns true if a directory can be created in the current location. * @return */ protected boolean canCreateDirectory() { final RootInfo root = getCurrentRoot(); final DocumentInfo cwd = getCurrentDirectory(); return cwd != null && cwd.isCreateSupported() && !mSearchManager.isSearching() && !root.isRecents(); } /** * Returns true if a directory can be inspected. */ protected boolean canInspectDirectory() { return false; } // TODO: make navigator listen to state @Override public final void updateNavigator() { mNavigator.update(); } @Override public void restoreRootAndDirectory() { // We're trying to restore stuff in document stack from saved instance. If we didn't have a // chance to spawn a fragment before we need to do it now. However if we spawned a fragment // already, system will automatically restore the fragment for us so we don't need to do // that manually this time. if (DirectoryFragment.get(getSupportFragmentManager()) == null) { refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); } } /** * Refreshes the content of the director and the menu/action bar. * The current directory name and selection will get updated. * @param anim */ @Override public final void refreshCurrentRootAndDirectory(int anim) { // The following call will crash if it's called before onCreateOptionMenu() is called in // which we install menu item to search view manager, and there is a search query we need to // restore. This happens when we're still initializing our UI so we shouldn't cancel the // search which will be restored later in onCreateOptionMenu(). Try finding a way to guard // refreshCurrentRootAndDirectory() from being called while we're restoring the state of UI // from the saved state passed in onCreate(). mSearchManager.cancelSearch(); // only set the query content in the first launch if (mHasQueryContentFromIntent) { mHasQueryContentFromIntent = false; mSearchManager.setCurrentSearch(mSearchManager.getQueryContentFromIntent()); } mState.derivedMode = LocalPreferences.getViewMode(this, mState.stack.getRoot(), MODE_GRID); mNavigator.update(); refreshDirectory(anim); final RootsFragment roots = RootsFragment.get(getSupportFragmentManager()); if (roots != null) { roots.onCurrentRootChanged(); } // Causes talkback to announce the activity's new title setTitle(mState.stack.getTitle()); invalidateOptionsMenu(); mSortController.onViewModeChanged(mState.derivedMode); mSearchManager.updateChips(getCurrentRoot().derivedMimeTypes); mAppsRowManager.updateView(this); } private final List getExcludedAuthorities() { List authorities = new ArrayList<>(); if (getIntent().getBooleanExtra(DocumentsContract.EXTRA_EXCLUDE_SELF, false)) { // Exclude roots provided by the calling package. String packageName = Shared.getCallingPackageName(this); try { PackageInfo pkgInfo = getPackageManager().getPackageInfo(packageName, PackageManager.GET_PROVIDERS); for (ProviderInfo provider: pkgInfo.providers) { authorities.add(provider.authority); } } catch (PackageManager.NameNotFoundException e) { Log.e(mTag, "Calling package name does not resolve: " + packageName); } } return authorities; } public static BaseActivity get(Fragment fragment) { return (BaseActivity) fragment.getActivity(); } public State getDisplayState() { return mState; } /** * Set internal storage visible based on explicit user action. */ private void onDisplayAdvancedDevices() { boolean display = !mState.showAdvanced; Metrics.logUserAction(display ? MetricConsts.USER_ACTION_SHOW_ADVANCED : MetricConsts.USER_ACTION_HIDE_ADVANCED); mInjector.prefs.setShowDeviceRoot(display); updateDisplayAdvancedDevices(display); } private void updateDisplayAdvancedDevices(boolean display) { mState.showAdvanced = display; @Nullable RootsFragment fragment = RootsFragment.get(getSupportFragmentManager()); if (fragment != null) { // This also takes care of updating launcher shortcuts (which are roots :) fragment.onDisplayStateChanged(); } invalidateOptionsMenu(); } /** * Set mode based on explicit user action. */ void setViewMode(@ViewMode int mode) { if (mode == State.MODE_GRID) { Metrics.logUserAction(MetricConsts.USER_ACTION_GRID); } else if (mode == State.MODE_LIST) { Metrics.logUserAction(MetricConsts.USER_ACTION_LIST); } LocalPreferences.setViewMode(this, getCurrentRoot(), mode); mState.derivedMode = mode; final ActionMenuView subMenuView = findViewById(R.id.sub_menu); mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); DirectoryFragment dir = getDirectoryFragment(); if (dir != null) { dir.onViewModeChanged(); } mSortController.onViewModeChanged(mode); } public void setPending(boolean pending) { // TODO: Isolate this behavior to PickActivity. } public void expandAppBar() { final AppBarLayout appBarLayout = findViewById(R.id.app_bar); if (appBarLayout != null) { appBarLayout.setExpanded(true); } } public void updateHeaderTitle() { if (!mState.stack.isInitialized()) { //stack has not initialized, the header will update after the stack finishes loading return; } final RootInfo root = mState.stack.getRoot(); final String rootTitle = root.title; String result; switch (root.derivedType) { case RootInfo.TYPE_RECENTS: result = getHeaderRecentTitle(); break; case RootInfo.TYPE_IMAGES: case RootInfo.TYPE_VIDEO: case RootInfo.TYPE_AUDIO: result = getString(R.string.root_info_header_media, rootTitle); break; case RootInfo.TYPE_DOWNLOADS: case RootInfo.TYPE_LOCAL: case RootInfo.TYPE_MTP: case RootInfo.TYPE_SD: case RootInfo.TYPE_USB: result = getHeaderStorageTitle(rootTitle); break; default: final String summary = root.summary; result = getHeaderDefaultTitle(rootTitle, summary); break; } TextView headerTitle = findViewById(R.id.header_title); headerTitle.setText(result); } private String getHeaderRecentTitle() { // If stack size larger than 1, it means user global search than enter a folder, but search // is not expanded on that time. boolean isGlobalSearch = mSearchManager.isSearching() || mState.stack.size() > 1; if (mState.isPhotoPicking()) { final int resId = isGlobalSearch ? R.string.root_info_header_image_global_search : R.string.root_info_header_image_recent; return getString(resId); } else { final int resId = isGlobalSearch ? R.string.root_info_header_global_search : R.string.root_info_header_recent; return getString(resId); } } private String getHeaderStorageTitle(String rootTitle) { final int resId = mState.isPhotoPicking() ? R.string.root_info_header_image_storage : R.string.root_info_header_storage; return getString(resId, rootTitle); } private String getHeaderDefaultTitle(String rootTitle, String summary) { if (TextUtils.isEmpty(summary)) { final int resId = mState.isPhotoPicking() ? R.string.root_info_header_image_app : R.string.root_info_header_app; return getString(resId, rootTitle); } else { final int resId = mState.isPhotoPicking() ? R.string.root_info_header_image_app_with_summary : R.string.root_info_header_app_with_summary; return getString(resId, rootTitle, summary); } } /** * Get title string equal to the string action bar displayed. * @return current directory title name */ public String getCurrentTitle() { if (!mState.stack.isInitialized()) { return null; } if (mState.stack.size() > 1) { return getCurrentDirectory().displayName; } else { return getCurrentRoot().title; } } @Override protected void onSaveInstanceState(Bundle state) { super.onSaveInstanceState(state); state.putParcelable(Shared.EXTRA_STATE, mState); mSearchManager.onSaveInstanceState(state); } @Override public boolean isSearchExpanded() { return mSearchManager.isExpanded(); } @Override public RootInfo getCurrentRoot() { RootInfo root = mState.stack.getRoot(); if (root != null) { return root; } else { return mProviders.getRecentsRoot(); } } @Override public DocumentInfo getCurrentDirectory() { return mState.stack.peek(); } @Override public boolean isInRecents() { return mState.stack.isRecents(); } @VisibleForTesting public void addEventListener(EventListener listener) { mEventListeners.add(listener); } @VisibleForTesting public void removeEventListener(EventListener listener) { mEventListeners.remove(listener); } @VisibleForTesting public void notifyDirectoryLoaded(Uri uri) { for (EventListener listener : mEventListeners) { listener.onDirectoryLoaded(uri); } } @VisibleForTesting @Override public void notifyDirectoryNavigated(Uri uri) { for (EventListener listener : mEventListeners) { listener.onDirectoryNavigated(uri); } } @Override public boolean dispatchKeyEvent(KeyEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN) { mInjector.debugHelper.debugCheck(event.getDownTime(), event.getKeyCode()); } DocumentsApplication.getDragAndDropManager(this).onKeyEvent(event); return super.dispatchKeyEvent(event); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); mInjector.actions.onActivityResult(requestCode, resultCode, data); } /** * Pops the top entry off the directory stack, and returns the user to the previous directory. * If the directory stack only contains one item, this method does nothing. * * @return Whether the stack was popped. */ protected boolean popDir() { if (mState.stack.size() > 1) { final DirectoryFragment fragment = getDirectoryFragment(); if (fragment != null) { fragment.stopScroll(); } mState.stack.pop(); refreshCurrentRootAndDirectory(AnimationView.ANIM_LEAVE); return true; } return false; } protected boolean focusSidebar() { RootsFragment rf = RootsFragment.get(getSupportFragmentManager()); assert (rf != null); return rf.requestFocus(); } /** * Closes the activity when it's idle. */ private void addListenerForLaunchCompletion() { addEventListener(new EventListener() { @Override public void onDirectoryNavigated(Uri uri) { } @Override public void onDirectoryLoaded(Uri uri) { removeEventListener(this); getMainLooper().getQueue().addIdleHandler(new IdleHandler() { @Override public boolean queueIdle() { // If startup benchmark is requested by a whitelisted testing package, then // close the activity once idle, and notify the testing activity. if (getIntent().getBooleanExtra(EXTRA_BENCHMARK, false) && BENCHMARK_TESTING_PACKAGE.equals(getCallingPackage())) { setResult(RESULT_OK); finish(); } Metrics.logStartupMs((int) (new Date().getTime() - mStartTime)); // Remove the idle handler. return false; } }); } }); } @VisibleForTesting protected interface EventListener { /** * @param uri Uri navigated to. If recents, then null. */ void onDirectoryNavigated(@Nullable Uri uri); /** * @param uri Uri of the loaded directory. If recents, then null. */ void onDirectoryLoaded(@Nullable Uri uri); } }