blob: 053dc93c8e628abdc5fe515babaf3f6a35de2a7e [file] [log] [blame]
/*
* 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 com.android.modules.utils.build.SdkLevel;
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<String> 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<String> 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<String> 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(androidx.appcompat.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();
});
}
if (SdkLevel.isAtLeastU()) {
final View textView = mSearchView.findViewById(androidx.appcompat.R.id.search_src_text);
if (textView != null) {
try {
textView.setIsHandwritingDelegate(true);
} catch (LinkageError e) {
// Running on a device with an older build of Android U
// TODO(b/274154553): Remove try/catch block after Android U Beta 1 is released
}
}
}
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();
}
}