/* * Copyright (C) 2017 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.settings.search; import android.app.Activity; import android.app.LoaderManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.Loader; import android.os.Bundle; import android.support.annotation.VisibleForTesting; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.LinearLayout; import android.widget.SearchView; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.Utils; import com.android.settings.core.InstrumentedFragment; import com.android.settings.core.instrumentation.MetricsFeatureProvider; import com.android.settings.overlay.FeatureFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; /** * This fragment manages the lifecycle of indexing and searching. * * In onCreate, the indexing process is initiated in DatabaseIndexingManager. * While the indexing is happening, loaders are blocked from accessing the database, but the user * is free to start typing their query. * * When the indexing is complete, the fragment gets a callback to initialize the loaders and search * the query if the user has entered text. */ public class SearchFragment extends InstrumentedFragment implements SearchView.OnQueryTextListener, LoaderManager.LoaderCallbacks>, IndexingCallback { private static final String TAG = "SearchFragment"; // State values private static final String STATE_QUERY = "state_query"; private static final String STATE_SHOWING_SAVED_QUERY = "state_showing_saved_query"; private static final String STATE_NEVER_ENTERED_QUERY = "state_never_entered_query"; private static final String STATE_RESULT_CLICK_COUNT = "state_result_click_count"; // Loader IDs @VisibleForTesting static final int LOADER_ID_DATABASE = 1; @VisibleForTesting static final int LOADER_ID_INSTALLED_APPS = 2; private static final int NUM_QUERY_LOADERS = 2; @VisibleForTesting AtomicInteger mUnfinishedLoadersCount = new AtomicInteger(NUM_QUERY_LOADERS); // Logging @VisibleForTesting static final String RESULT_CLICK_COUNT = "settings_search_result_click_count"; @VisibleForTesting String mQuery; private boolean mNeverEnteredQuery = true; @VisibleForTesting boolean mShowingSavedQuery; private int mResultClickCount; private MetricsFeatureProvider mMetricsFeatureProvider; @VisibleForTesting SavedQueryController mSavedQueryController; @VisibleForTesting SearchFeatureProvider mSearchFeatureProvider; private SearchResultsAdapter mSearchAdapter; @VisibleForTesting RecyclerView mResultsRecyclerView; @VisibleForTesting SearchView mSearchView; @VisibleForTesting LinearLayout mNoResultsView; @VisibleForTesting final RecyclerView.OnScrollListener mScrollListener = new RecyclerView.OnScrollListener() { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { if (dy != 0) { hideKeyboard(); } } }; @Override public int getMetricsCategory() { return MetricsEvent.DASHBOARD_SEARCH_RESULTS; } @Override public void onAttach(Context context) { super.onAttach(context); mSearchFeatureProvider = FeatureFactory.getFactory(context).getSearchFeatureProvider(); mMetricsFeatureProvider = FeatureFactory.getFactory(context).getMetricsFeatureProvider(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); final LoaderManager loaderManager = getLoaderManager(); mSearchAdapter = new SearchResultsAdapter(this, mSearchFeatureProvider); mSavedQueryController = new SavedQueryController( getContext(), loaderManager, mSearchAdapter); mSearchFeatureProvider.initFeedbackButton(); if (savedInstanceState != null) { mQuery = savedInstanceState.getString(STATE_QUERY); mNeverEnteredQuery = savedInstanceState.getBoolean(STATE_NEVER_ENTERED_QUERY); mResultClickCount = savedInstanceState.getInt(STATE_RESULT_CLICK_COUNT); mShowingSavedQuery = savedInstanceState.getBoolean(STATE_SHOWING_SAVED_QUERY); } else { mShowingSavedQuery = true; } final Activity activity = getActivity(); // Run the Index update only if we have some space if (!Utils.isLowStorage(activity)) { mSearchFeatureProvider.updateIndex(activity, this /* indexingCallback */); } else { Log.w(TAG, "Cannot update the Indexer as we are running low on storage space!"); } } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.search_panel, container, false); mResultsRecyclerView = view.findViewById(R.id.list_results); mResultsRecyclerView.setAdapter(mSearchAdapter); mResultsRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); mResultsRecyclerView.addOnScrollListener(mScrollListener); mResultsRecyclerView.addItemDecoration(new HeaderDecorator()); mNoResultsView = view.findViewById(R.id.no_results_layout); mSearchView = view.findViewById(R.id.search_view); mSearchView.setQuery(mQuery, false /* submitQuery */); mSearchView.setOnQueryTextListener(this); mSearchView.requestFocus(); return view; } @Override public void onResume() { super.onResume(); requery(); } @Override public void onStop() { super.onStop(); final Activity activity = getActivity(); if (activity != null && activity.isFinishing()) { mMetricsFeatureProvider.histogram(activity, RESULT_CLICK_COUNT, mResultClickCount); if (mNeverEnteredQuery) { mMetricsFeatureProvider.action(activity, MetricsEvent.ACTION_LEAVE_SEARCH_RESULT_WITHOUT_QUERY); } } } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(STATE_QUERY, mQuery); outState.putBoolean(STATE_NEVER_ENTERED_QUERY, mNeverEnteredQuery); outState.putBoolean(STATE_SHOWING_SAVED_QUERY, mShowingSavedQuery); outState.putInt(STATE_RESULT_CLICK_COUNT, mResultClickCount); } @Override public boolean onQueryTextChange(String query) { if (TextUtils.equals(query, mQuery)) { return true; } final boolean isEmptyQuery = TextUtils.isEmpty(query); // Hide no-results-view when the new query is not a super-string of the previous if (mQuery != null && mNoResultsView.getVisibility() == View.VISIBLE && query.length() < mQuery.length()) { mNoResultsView.setVisibility(View.GONE); } mResultClickCount = 0; mNeverEnteredQuery = false; mQuery = query; // If indexing is not finished, register the query text, but don't search. if (!mSearchFeatureProvider.isIndexingComplete(getActivity())) { return true; } if (isEmptyQuery) { final LoaderManager loaderManager = getLoaderManager(); loaderManager.destroyLoader(LOADER_ID_DATABASE); loaderManager.destroyLoader(LOADER_ID_INSTALLED_APPS); mShowingSavedQuery = true; mSavedQueryController.loadSavedQueries(); mSearchFeatureProvider.hideFeedbackButton(); } else { mSearchAdapter.initializeSearch(mQuery); restartLoaders(); } return true; } @Override public boolean onQueryTextSubmit(String query) { // Save submitted query. mSavedQueryController.saveQuery(mQuery); hideKeyboard(); return true; } @Override public Loader> onCreateLoader(int id, Bundle args) { final Activity activity = getActivity(); switch (id) { case LOADER_ID_DATABASE: return mSearchFeatureProvider.getDatabaseSearchLoader(activity, mQuery); case LOADER_ID_INSTALLED_APPS: return mSearchFeatureProvider.getInstalledAppSearchLoader(activity, mQuery); default: return null; } } @Override public void onLoadFinished(Loader> loader, Set data) { mSearchAdapter.addSearchResults(data, loader.getClass().getName()); if (mUnfinishedLoadersCount.decrementAndGet() != 0) { return; } mSearchAdapter.notifyResultsLoaded(); } @Override public void onLoaderReset(Loader> loader) { } /** * Gets called when Indexing is completed. */ @Override public void onIndexingFinished() { if (getActivity() == null) { return; } if (mShowingSavedQuery) { mSavedQueryController.loadSavedQueries(); } else { final LoaderManager loaderManager = getLoaderManager(); loaderManager.initLoader(LOADER_ID_DATABASE, null, this); loaderManager.initLoader(LOADER_ID_INSTALLED_APPS, null, this); } requery(); } public void onSearchResultClicked(SearchViewHolder resultViewHolder, SearchResult result, Pair... logTaggedData) { logSearchResultClicked(resultViewHolder, result, logTaggedData); mSearchFeatureProvider.searchResultClicked(getContext(), mQuery, result); mSavedQueryController.saveQuery(mQuery); mResultClickCount++; } public void onSearchResultsDisplayed(int resultCount) { if (resultCount == 0) { mNoResultsView.setVisibility(View.VISIBLE); mMetricsFeatureProvider.visible(getContext(), getMetricsCategory(), MetricsEvent.SETTINGS_SEARCH_NO_RESULT); } else { mNoResultsView.setVisibility(View.GONE); mResultsRecyclerView.scrollToPosition(0); } mSearchFeatureProvider.showFeedbackButton(this, getView()); } public void onSavedQueryClicked(CharSequence query) { final String queryString = query.toString(); mMetricsFeatureProvider.action(getContext(), MetricsEvent.ACTION_CLICK_SETTINGS_SEARCH_SAVED_QUERY); mSearchView.setQuery(queryString, false /* submit */); onQueryTextChange(queryString); } public void onRemoveSavedQueryClicked(CharSequence title) { mSavedQueryController.removeQuery(title.toString()); } private void restartLoaders() { mShowingSavedQuery = false; final LoaderManager loaderManager = getLoaderManager(); mUnfinishedLoadersCount.set(NUM_QUERY_LOADERS); loaderManager.restartLoader(LOADER_ID_DATABASE, null /* args */, this /* callback */); loaderManager.restartLoader(LOADER_ID_INSTALLED_APPS, null /* args */, this /* callback */); } public String getQuery() { return mQuery; } public List getSearchResults() { return mSearchAdapter.getSearchResults(); } private void requery() { if (TextUtils.isEmpty(mQuery)) { return; } final String query = mQuery; mQuery = ""; onQueryTextChange(query); } private void hideKeyboard() { final Activity activity = getActivity(); if (activity != null) { View view = activity.getCurrentFocus(); InputMethodManager imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(view.getWindowToken(), 0); } if (mResultsRecyclerView != null) { mResultsRecyclerView.requestFocus(); } } private void logSearchResultClicked(SearchViewHolder resultViewHolder, SearchResult result, Pair... logTaggedData) { final Intent intent = result.payload.getIntent(); if (intent == null) { Log.w(TAG, "Skipped logging click on search result because of null intent, which can " + "happen on saved query results."); return; } final ComponentName cn = intent.getComponent(); String resultName = intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT); if (TextUtils.isEmpty(resultName) && cn != null) { resultName = cn.flattenToString(); } final List> taggedData = new ArrayList<>(); if (logTaggedData != null) { taggedData.addAll(Arrays.asList(logTaggedData)); } taggedData.add(Pair.create( MetricsEvent.FIELD_SETTINGS_SERACH_RESULT_COUNT, mSearchAdapter.getItemCount())); taggedData.add(Pair.create( MetricsEvent.FIELD_SETTINGS_SERACH_RESULT_RANK, resultViewHolder.getAdapterPosition())); taggedData.add(Pair.create( MetricsEvent.FIELD_SETTINGS_SERACH_QUERY_LENGTH, TextUtils.isEmpty(mQuery) ? 0 : mQuery.length())); mMetricsFeatureProvider.action(getContext(), resultViewHolder.getClickActionMetricName(), resultName, taggedData.toArray(new Pair[0])); } }