/* * Copyright (C) 2013 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.queries; import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.State.ACTION_GET_CONTENT; import static com.android.documentsui.base.State.ACTION_OPEN; import static com.android.documentsui.base.State.ActionType; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.DocumentsContract; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.MenuItem.OnActionExpandListener; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnFocusChangeListener; import android.view.ViewGroup; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.SearchView.OnQueryTextListener; import androidx.fragment.app.FragmentManager; import com.android.documentsui.MetricConsts; import com.android.documentsui.Metrics; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; 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 java.util.Timer; import java.util.TimerTask; /** * Manages searching UI behavior. */ public class SearchViewManager implements SearchView.OnCloseListener, OnQueryTextListener, OnClickListener, OnFocusChangeListener, OnActionExpandListener { private static final String TAG = "SearchManager"; // How long we wait after the user finishes typing before kicking off a search. public static final int SEARCH_DELAY_MS = 750; private final SearchManagerListener mListener; private final EventHandler mCommandProcessor; private final SearchChipViewManager mChipViewManager; private final Timer mTimer; private final Handler mUiHandler; private final Object mSearchLock; @GuardedBy("mSearchLock") private @Nullable Runnable mQueuedSearchRunnable; @GuardedBy("mSearchLock") private @Nullable TimerTask mQueuedSearchTask; private @Nullable String mCurrentSearch; private String mQueryContentFromIntent; private boolean mSearchExpanded; private boolean mIgnoreNextClose; private boolean mFullBar; private boolean mIsHistorySearch; private boolean mShowSearchBar; private @Nullable Menu mMenu; private @Nullable MenuItem mMenuItem; private @Nullable SearchView mSearchView; private @Nullable FragmentManager mFragmentManager; public SearchViewManager( SearchManagerListener listener, EventHandler commandProcessor, ViewGroup chipGroup, @Nullable Bundle savedState) { this(listener, commandProcessor, new SearchChipViewManager(chipGroup), savedState, new Timer(), new Handler(Looper.getMainLooper())); } @VisibleForTesting protected SearchViewManager( SearchManagerListener listener, EventHandler commandProcessor, SearchChipViewManager chipViewManager, @Nullable Bundle savedState, Timer timer, Handler handler) { assert (listener != null); assert (commandProcessor != null); mSearchLock = new Object(); mListener = listener; mCommandProcessor = commandProcessor; mTimer = timer; mUiHandler = handler; mChipViewManager = chipViewManager; mChipViewManager.setSearchChipViewManagerListener(this::onChipCheckedStateChanged); if (savedState != null) { mCurrentSearch = savedState.getString(Shared.EXTRA_QUERY); mChipViewManager.restoreCheckedChipItems(savedState); } else { mCurrentSearch = null; } } private void onChipCheckedStateChanged(View v) { mListener.onSearchChipStateChanged(v); performSearch(mCurrentSearch); } /** * Parse the query content from Intent. If the action is not {@link State#ACTION_GET_CONTENT} * or {@link State#ACTION_OPEN}, don't perform search. * @param intent the intent to parse. * @param action the action to check. * @return True, if get the query content from the intent. Otherwise, false. */ public boolean parseQueryContentFromIntent(Intent intent, @ActionType int action) { if (action == ACTION_OPEN || action == ACTION_GET_CONTENT) { final String queryString = intent.getStringExtra(Intent.EXTRA_CONTENT_QUERY); if (!TextUtils.isEmpty(queryString)) { mQueryContentFromIntent = queryString; return true; } } return false; } /** * Build the bundle of query arguments. * Example: search string and mime types * * @return the bundle of query arguments */ public Bundle buildQueryArgs() { final Bundle queryArgs = mChipViewManager.getCheckedChipQueryArgs(); if (!TextUtils.isEmpty(mCurrentSearch)) { queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mCurrentSearch); } else if (isExpanded() && isSearching()) { // The existence of the DocumentsContract.QUERY_ARG_DISPLAY_NAME constant is used to // determine if this is a text search (as opposed to simply filtering from within a // non-searching view), so ensure the argument exists when searching. queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, ""); } return queryArgs; } /** * Initialize the search chips base on the acceptMimeTypes. * * @param acceptMimeTypes use to filter chips */ public void initChipSets(String[] acceptMimeTypes) { mChipViewManager.initChipSets(acceptMimeTypes); } /** * Update the search chips base on the acceptMimeTypes. * If the count of matched chips is less than two, we will * hide the chip row. * * @param acceptMimeTypes use to filter chips */ public void updateChips(String[] acceptMimeTypes) { mChipViewManager.updateChips(acceptMimeTypes); } /** * Bind chip data in ChipViewManager on other view groups * * @param chipGroup target view group for bind ChipViewManager data */ public void bindChips(ViewGroup chipGroup) { mChipViewManager.bindMirrorGroup(chipGroup); } /** * Click behavior when chip in synced chip group click. * * @param data SearchChipData synced in mirror group */ public void onMirrorChipClick(SearchChipData data) { mChipViewManager.onMirrorChipClick(data); mSearchView.clearFocus(); } /** * Initailize search view by option menu. * * @param menu the menu include search view * @param isFullBarSearch whether hide other menu when search view expand * @param isShowSearchBar whether replace collapsed search view by search hint text */ public void install(Menu menu, boolean isFullBarSearch, boolean isShowSearchBar) { mMenu = menu; mMenuItem = mMenu.findItem(R.id.option_menu_search); mSearchView = (SearchView) mMenuItem.getActionView(); mSearchView.setOnQueryTextListener(this); mSearchView.setOnCloseListener(this); mSearchView.setOnSearchClickListener(this); mSearchView.setOnQueryTextFocusChangeListener(this); final View clearButton = mSearchView.findViewById(R.id.search_close_btn); if (clearButton != null) { clearButton.setPadding(clearButton.getPaddingStart() + getPixelForDp(4), clearButton.getPaddingTop(), clearButton.getPaddingEnd() + getPixelForDp(4), clearButton.getPaddingBottom()); clearButton.setOnClickListener(v -> { mSearchView.setQuery("", false); mSearchView.requestFocus(); mListener.onSearchViewClearClicked(); }); } mFullBar = isFullBarSearch; mShowSearchBar = isShowSearchBar; mSearchView.setMaxWidth(Integer.MAX_VALUE); mMenuItem.setOnActionExpandListener(this); restoreSearch(true); } public void setFragmentManager(FragmentManager fragmentManager) { mFragmentManager = fragmentManager; } /** * Used to hide menu icons, when the search is being restored. Needed because search restoration * is done before onPrepareOptionsMenu(Menu menu) that is overriding the icons visibility. */ public void updateMenu() { if (mMenu != null && isExpanded() && mFullBar) { mMenu.setGroupVisible(R.id.group_hide_when_searching, false); } } /** * @param stack New stack. */ public void update(DocumentStack stack) { if (mMenuItem == null || mSearchView == null) { if (DEBUG) { Log.d(TAG, "update called before Search MenuItem installed."); } return; } if (mCurrentSearch != null) { mMenuItem.expandActionView(); mSearchView.setIconified(false); mSearchView.clearFocus(); mSearchView.setQuery(mCurrentSearch, false); } else { mSearchView.clearFocus(); if (!mSearchView.isIconified()) { mIgnoreNextClose = true; mSearchView.setIconified(true); } if (mMenuItem.isActionViewExpanded()) { mMenuItem.collapseActionView(); } } showMenu(stack); } public void showMenu(@Nullable DocumentStack stack) { final DocumentInfo cwd = stack != null ? stack.peek() : null; boolean supportsSearch = true; // Searching in archives is not enabled, as archives are backed by // a different provider than the root provider. if (cwd != null && cwd.isInArchive()) { supportsSearch = false; } final RootInfo root = stack != null ? stack.getRoot() : null; if (root == null || !root.supportsSearch()) { supportsSearch = false; } if (mMenuItem == null) { if (DEBUG) { Log.d(TAG, "showMenu called before Search MenuItem installed."); } return; } if (!supportsSearch) { mCurrentSearch = null; } // Recent root show open search bar, do not show duplicate search icon. mMenuItem.setVisible(supportsSearch && (!stack.isRecents() || !mShowSearchBar)); mChipViewManager.setChipsRowVisible(supportsSearch && root.supportsMimeTypesSearch()); } /** * Cancels current search operation. Triggers clearing and collapsing the SearchView. * * @return True if it cancels search. False if it does not operate search currently. */ public boolean cancelSearch() { if (mSearchView != null && (isExpanded() || isSearching())) { cancelQueuedSearch(); if (mFullBar) { onClose(); } else { // Causes calling onClose(). onClose() is triggering directory content update. mSearchView.setIconified(true); } return true; } return false; } private int getPixelForDp(int dp) { final float scale = mSearchView.getContext().getResources().getDisplayMetrics().density; return (int) (dp * scale + 0.5f); } private void cancelQueuedSearch() { synchronized (mSearchLock) { if (mQueuedSearchTask != null) { mQueuedSearchTask.cancel(); } mQueuedSearchTask = null; mUiHandler.removeCallbacks(mQueuedSearchRunnable); mQueuedSearchRunnable = null; mIsHistorySearch = false; } } /** * Sets search view into the searching state. Used to restore state after device orientation * change. */ public void restoreSearch(boolean keepFocus) { if (mSearchView == null) { return; } if (isTextSearching()) { onSearchBarClicked(); mSearchView.setQuery(mCurrentSearch, false); if (keepFocus) { mSearchView.requestFocus(); } else { mSearchView.clearFocus(); } } } public void onSearchBarClicked() { if (mMenuItem == null) { return; } mMenuItem.expandActionView(); onSearchExpanded(); } private void onSearchExpanded() { mSearchExpanded = true; if (mFullBar && mMenu != null) { mMenu.setGroupVisible(R.id.group_hide_when_searching, false); } mListener.onSearchViewChanged(true); } /** * Clears the search. Triggers refreshing of the directory content. * * @return True if the default behavior of clearing/dismissing SearchView should be overridden. * False otherwise. */ @Override public boolean onClose() { mSearchExpanded = false; if (mIgnoreNextClose) { mIgnoreNextClose = false; return false; } // Refresh the directory if a search was done if (mCurrentSearch != null || mChipViewManager.hasCheckedItems()) { // Make sure SearchFragment was dismissed. if (mFragmentManager != null) { SearchFragment.dismissFragment(mFragmentManager); } // Clear checked chips mChipViewManager.clearCheckedChips(); mCurrentSearch = null; mListener.onSearchChanged(mCurrentSearch); } if (mFullBar && mMenuItem != null) { mMenuItem.collapseActionView(); } mListener.onSearchFinished(); mListener.onSearchViewChanged(false); return false; } /** * Called when owning activity is saving state to be used to restore state during creation. * * @param state Bundle to save state too */ public void onSaveInstanceState(Bundle state) { if (mSearchView != null && mSearchView.hasFocus() && mCurrentSearch == null) { // Restore focus even if no text was input before screen rotation. mCurrentSearch = ""; } state.putString(Shared.EXTRA_QUERY, mCurrentSearch); mChipViewManager.onSaveInstanceState(state); } /** * Sets mSearchExpanded. Called when search icon is clicked to start search for both search view * modes. */ @Override public void onClick(View v) { onSearchExpanded(); } @Override public boolean onQueryTextSubmit(String query) { if (mCommandProcessor.accept(query)) { mSearchView.setQuery("", false); } else { cancelQueuedSearch(); // Don't kick off a search if we've already finished it. if (!TextUtils.equals(mCurrentSearch, query)) { mCurrentSearch = query; mListener.onSearchChanged(mCurrentSearch); } recordHistory(); mSearchView.clearFocus(); } return true; } /** * Used to detect and handle back button pressed event when search is expanded. */ @Override public void onFocusChange(View v, boolean hasFocus) { if (!hasFocus && !mChipViewManager.hasCheckedItems()) { if (mSearchView != null && mCurrentSearch == null) { mSearchView.setIconified(true); } else if (TextUtils.isEmpty(getSearchViewText())) { cancelSearch(); } } mListener.onSearchViewFocusChanged(hasFocus); } @VisibleForTesting protected TimerTask createSearchTask(String newText) { return new TimerTask() { @Override public void run() { // Do the actual work on the main looper. synchronized (mSearchLock) { mQueuedSearchRunnable = () -> { mCurrentSearch = newText; if (mCurrentSearch != null && mCurrentSearch.isEmpty()) { mCurrentSearch = null; } logTextSearchMetric(); mListener.onSearchChanged(mCurrentSearch); }; mUiHandler.post(mQueuedSearchRunnable); } } }; } @Override public boolean onQueryTextChange(String newText) { //Skip first search when search expanded if (mCurrentSearch == null && newText.isEmpty()) { return true; } performSearch(newText); if (mFragmentManager != null) { if (!newText.isEmpty()) { SearchFragment.dismissFragment(mFragmentManager); } else { SearchFragment.showFragment(mFragmentManager, ""); } } return true; } private void performSearch(String newText) { cancelQueuedSearch(); synchronized (mSearchLock) { mQueuedSearchTask = createSearchTask(newText); mTimer.schedule(mQueuedSearchTask, SEARCH_DELAY_MS); } } @Override public boolean onMenuItemActionCollapse(MenuItem item) { mMenu.setGroupVisible(R.id.group_hide_when_searching, true); // Handles case when search view is collapsed by using the arrow on the left of the bar if (isExpanded() || isSearching()) { cancelSearch(); return false; } return true; } @Override public boolean onMenuItemActionExpand(MenuItem item) { return true; } public String getCurrentSearch() { return mCurrentSearch; } /** * Get current text on search view. * * @return Current string on search view */ public String getSearchViewText() { if (mSearchView == null) { return null; } return mSearchView.getQuery().toString(); } /** * Record current search for history. */ public void recordHistory() { if (TextUtils.isEmpty(mCurrentSearch)) { return; } recordHistoryInternal(); } protected void recordHistoryInternal() { if (mSearchView == null) { Log.w(TAG, "Search view is null, skip record history this time"); return; } SearchHistoryManager.getInstance( mSearchView.getContext().getApplicationContext()).addHistory(mCurrentSearch); } /** * Remove specific text item in history list. * * @param history target string for removed. */ public void removeHistory(String history) { if (mSearchView == null) { Log.w(TAG, "Search view is null, skip remove history this time"); return; } SearchHistoryManager.getInstance( mSearchView.getContext().getApplicationContext()).deleteHistory(history); } private void logTextSearchMetric() { if (isTextSearching()) { Metrics.logUserAction(mIsHistorySearch ? MetricConsts.USER_ACTION_SEARCH_HISTORY : MetricConsts.USER_ACTION_SEARCH); Metrics.logSearchType(mIsHistorySearch ? MetricConsts.TYPE_SEARCH_HISTORY : MetricConsts.TYPE_SEARCH_STRING); mIsHistorySearch = false; } } /** * Get the query content from intent. * @return If has query content, return the query content. Otherwise, return null * @see #parseQueryContentFromIntent(Intent, int) */ public String getQueryContentFromIntent() { return mQueryContentFromIntent; } public void setCurrentSearch(String queryString) { mCurrentSearch = queryString; } /** * Set next search type is history search. */ public void setHistorySearch() { mIsHistorySearch = true; } public boolean isSearching() { return mCurrentSearch != null || mChipViewManager.hasCheckedItems(); } public boolean isTextSearching() { return mCurrentSearch != null; } public boolean hasCheckedChip() { return mChipViewManager.hasCheckedItems(); } public boolean isExpanded() { return mSearchExpanded; } public interface SearchManagerListener { void onSearchChanged(@Nullable String query); void onSearchFinished(); void onSearchViewChanged(boolean opened); void onSearchChipStateChanged(View v); void onSearchViewFocusChanged(boolean hasFocus); /** * Call back when search view clear button clicked */ void onSearchViewClearClicked(); } }