1 /* 2 * Copyright (C) 2017 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 18 package com.android.settings.search; 19 20 import android.app.Activity; 21 import android.app.LoaderManager; 22 import android.content.ComponentName; 23 import android.content.Context; 24 import android.content.Intent; 25 import android.content.Loader; 26 import android.os.Bundle; 27 import android.support.annotation.VisibleForTesting; 28 import android.support.v7.widget.LinearLayoutManager; 29 import android.support.v7.widget.RecyclerView; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.util.Pair; 33 import android.util.TypedValue; 34 import android.view.LayoutInflater; 35 import android.view.Menu; 36 import android.view.MenuInflater; 37 import android.view.View; 38 import android.view.ViewGroup; 39 import android.view.inputmethod.InputMethodManager; 40 import android.widget.LinearLayout; 41 import android.widget.SearchView; 42 import android.widget.TextView; 43 import android.widget.Toolbar; 44 45 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 46 import com.android.settings.R; 47 import com.android.settings.SettingsActivity; 48 import com.android.settings.Utils; 49 import com.android.settings.core.InstrumentedFragment; 50 import com.android.settings.core.instrumentation.MetricsFeatureProvider; 51 import com.android.settings.overlay.FeatureFactory; 52 import com.android.settings.widget.ActionBarShadowController; 53 54 import java.util.ArrayList; 55 import java.util.Arrays; 56 import java.util.List; 57 import java.util.Set; 58 import java.util.concurrent.atomic.AtomicInteger; 59 60 /** 61 * This fragment manages the lifecycle of indexing and searching. 62 * 63 * In onCreate, the indexing process is initiated in DatabaseIndexingManager. 64 * While the indexing is happening, loaders are blocked from accessing the database, but the user 65 * is free to start typing their query. 66 * 67 * When the indexing is complete, the fragment gets a callback to initialize the loaders and search 68 * the query if the user has entered text. 69 */ 70 public class SearchFragment extends InstrumentedFragment implements SearchView.OnQueryTextListener, 71 LoaderManager.LoaderCallbacks<Set<? extends SearchResult>>, IndexingCallback { 72 private static final String TAG = "SearchFragment"; 73 74 // State values 75 private static final String STATE_QUERY = "state_query"; 76 private static final String STATE_SHOWING_SAVED_QUERY = "state_showing_saved_query"; 77 private static final String STATE_NEVER_ENTERED_QUERY = "state_never_entered_query"; 78 private static final String STATE_RESULT_CLICK_COUNT = "state_result_click_count"; 79 80 static final class SearchLoaderId { 81 // Search Query IDs 82 public static final int DATABASE = 1; 83 public static final int INSTALLED_APPS = 2; 84 public static final int ACCESSIBILITY_SERVICES = 3; 85 public static final int INPUT_DEVICES = 4; 86 87 // Saved Query IDs 88 public static final int SAVE_QUERY_TASK = 5; 89 public static final int REMOVE_QUERY_TASK = 6; 90 public static final int SAVED_QUERIES = 7; 91 } 92 93 94 private static final int NUM_QUERY_LOADERS = 4; 95 96 @VisibleForTesting 97 AtomicInteger mUnfinishedLoadersCount = new AtomicInteger(NUM_QUERY_LOADERS); 98 99 // Logging 100 @VisibleForTesting 101 static final String RESULT_CLICK_COUNT = "settings_search_result_click_count"; 102 103 @VisibleForTesting 104 String mQuery; 105 106 private boolean mNeverEnteredQuery = true; 107 @VisibleForTesting 108 boolean mShowingSavedQuery; 109 private int mResultClickCount; 110 private MetricsFeatureProvider mMetricsFeatureProvider; 111 @VisibleForTesting 112 SavedQueryController mSavedQueryController; 113 114 @VisibleForTesting 115 SearchFeatureProvider mSearchFeatureProvider; 116 117 @VisibleForTesting 118 SearchResultsAdapter mSearchAdapter; 119 120 @VisibleForTesting 121 RecyclerView mResultsRecyclerView; 122 @VisibleForTesting 123 SearchView mSearchView; 124 @VisibleForTesting 125 LinearLayout mNoResultsView; 126 127 @VisibleForTesting 128 final RecyclerView.OnScrollListener mScrollListener = new RecyclerView.OnScrollListener() { 129 @Override 130 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 131 if (dy != 0) { 132 hideKeyboard(); 133 } 134 } 135 }; 136 137 @Override 138 public int getMetricsCategory() { 139 return MetricsEvent.DASHBOARD_SEARCH_RESULTS; 140 } 141 142 @Override 143 public void onAttach(Context context) { 144 super.onAttach(context); 145 mSearchFeatureProvider = FeatureFactory.getFactory(context).getSearchFeatureProvider(); 146 mMetricsFeatureProvider = FeatureFactory.getFactory(context).getMetricsFeatureProvider(); 147 } 148 149 @Override 150 public void onCreate(Bundle savedInstanceState) { 151 super.onCreate(savedInstanceState); 152 long startTime = System.currentTimeMillis(); 153 setHasOptionsMenu(true); 154 155 final LoaderManager loaderManager = getLoaderManager(); 156 mSearchAdapter = new SearchResultsAdapter(this, mSearchFeatureProvider); 157 mSavedQueryController = new SavedQueryController( 158 getContext(), loaderManager, mSearchAdapter); 159 mSearchFeatureProvider.initFeedbackButton(); 160 161 if (savedInstanceState != null) { 162 mQuery = savedInstanceState.getString(STATE_QUERY); 163 mNeverEnteredQuery = savedInstanceState.getBoolean(STATE_NEVER_ENTERED_QUERY); 164 mResultClickCount = savedInstanceState.getInt(STATE_RESULT_CLICK_COUNT); 165 mShowingSavedQuery = savedInstanceState.getBoolean(STATE_SHOWING_SAVED_QUERY); 166 } else { 167 mShowingSavedQuery = true; 168 } 169 170 final Activity activity = getActivity(); 171 // Run the Index update only if we have some space 172 if (!Utils.isLowStorage(activity)) { 173 mSearchFeatureProvider.updateIndexAsync(activity, this /* indexingCallback */); 174 } else { 175 Log.w(TAG, "Cannot update the Indexer as we are running low on storage space!"); 176 } 177 if (SettingsSearchIndexablesProvider.DEBUG) { 178 Log.d(TAG, "onCreate spent " + (System.currentTimeMillis() - startTime) + " ms"); 179 } 180 } 181 182 @Override 183 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 184 super.onCreateOptionsMenu(menu, inflater); 185 mSavedQueryController.buildMenuItem(menu); 186 } 187 188 @Override 189 public View onCreateView(LayoutInflater inflater, ViewGroup container, 190 Bundle savedInstanceState) { 191 final View view = inflater.inflate(R.layout.search_panel, container, false); 192 mResultsRecyclerView = view.findViewById(R.id.list_results); 193 mResultsRecyclerView.setAdapter(mSearchAdapter); 194 mResultsRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); 195 mResultsRecyclerView.addOnScrollListener(mScrollListener); 196 197 mNoResultsView = view.findViewById(R.id.no_results_layout); 198 199 Toolbar toolbar = view.findViewById(R.id.search_toolbar); 200 getActivity().setActionBar(toolbar); 201 getActivity().getActionBar().setDisplayHomeAsUpEnabled(true); 202 203 mSearchView = toolbar.findViewById(R.id.search_view); 204 mSearchView.setQuery(mQuery, false /* submitQuery */); 205 mSearchView.setOnQueryTextListener(this); 206 mSearchView.requestFocus(); 207 208 // Updating internal views inside SearchView was the easiest way to get this too look right. 209 // Instead of grabbing the TextView directly, we grab it as a view and do an instanceof 210 // check. This ensures if we return, say, a LinearLayout in the tests, they won't fail. 211 View searchText = mSearchView.findViewById(com.android.internal.R.id.search_src_text); 212 if (searchText instanceof TextView) { 213 TextView searchTextView = (TextView) searchText; 214 searchTextView.setTextColor(getContext().getColorStateList( 215 com.android.internal.R.color.text_color_primary)); 216 searchTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, 217 getResources().getDimension(R.dimen.search_bar_text_size)); 218 219 } 220 View editFrame = mSearchView.findViewById(com.android.internal.R.id.search_edit_frame); 221 if (editFrame != null) { 222 ViewGroup.MarginLayoutParams params = 223 (ViewGroup.MarginLayoutParams) editFrame.getLayoutParams(); 224 params.setMarginStart(0); 225 editFrame.setLayoutParams(params); 226 } 227 ActionBarShadowController.attachToRecyclerView( 228 view.findViewById(R.id.search_bar_container), getLifecycle(), mResultsRecyclerView); 229 return view; 230 } 231 232 @Override 233 public void onResume() { 234 super.onResume(); 235 Context appContext = getContext().getApplicationContext(); 236 if (mSearchFeatureProvider.isSmartSearchRankingEnabled(appContext)) { 237 mSearchFeatureProvider.searchRankingWarmup(appContext); 238 } 239 requery(); 240 } 241 242 @Override 243 public void onStop() { 244 super.onStop(); 245 final Activity activity = getActivity(); 246 if (activity != null && activity.isFinishing()) { 247 mMetricsFeatureProvider.histogram(activity, RESULT_CLICK_COUNT, mResultClickCount); 248 if (mNeverEnteredQuery) { 249 mMetricsFeatureProvider.action(activity, 250 MetricsEvent.ACTION_LEAVE_SEARCH_RESULT_WITHOUT_QUERY); 251 } 252 } 253 } 254 255 @Override 256 public void onSaveInstanceState(Bundle outState) { 257 super.onSaveInstanceState(outState); 258 outState.putString(STATE_QUERY, mQuery); 259 outState.putBoolean(STATE_NEVER_ENTERED_QUERY, mNeverEnteredQuery); 260 outState.putBoolean(STATE_SHOWING_SAVED_QUERY, mShowingSavedQuery); 261 outState.putInt(STATE_RESULT_CLICK_COUNT, mResultClickCount); 262 } 263 264 @Override 265 public boolean onQueryTextChange(String query) { 266 if (TextUtils.equals(query, mQuery)) { 267 return true; 268 } 269 270 final boolean isEmptyQuery = TextUtils.isEmpty(query); 271 272 // Hide no-results-view when the new query is not a super-string of the previous 273 if (mQuery != null 274 && mNoResultsView.getVisibility() == View.VISIBLE 275 && query.length() < mQuery.length()) { 276 mNoResultsView.setVisibility(View.GONE); 277 } 278 279 mResultClickCount = 0; 280 mNeverEnteredQuery = false; 281 mQuery = query; 282 283 // If indexing is not finished, register the query text, but don't search. 284 if (!mSearchFeatureProvider.isIndexingComplete(getActivity())) { 285 return true; 286 } 287 288 if (isEmptyQuery) { 289 final LoaderManager loaderManager = getLoaderManager(); 290 loaderManager.destroyLoader(SearchLoaderId.DATABASE); 291 loaderManager.destroyLoader(SearchLoaderId.INSTALLED_APPS); 292 loaderManager.destroyLoader(SearchLoaderId.ACCESSIBILITY_SERVICES); 293 loaderManager.destroyLoader(SearchLoaderId.INPUT_DEVICES); 294 mShowingSavedQuery = true; 295 mSavedQueryController.loadSavedQueries(); 296 mSearchFeatureProvider.hideFeedbackButton(); 297 } else { 298 mSearchAdapter.initializeSearch(mQuery); 299 restartLoaders(); 300 } 301 302 return true; 303 } 304 305 @Override 306 public boolean onQueryTextSubmit(String query) { 307 // Save submitted query. 308 mSavedQueryController.saveQuery(mQuery); 309 hideKeyboard(); 310 return true; 311 } 312 313 @Override 314 public Loader<Set<? extends SearchResult>> onCreateLoader(int id, Bundle args) { 315 final Activity activity = getActivity(); 316 317 switch (id) { 318 case SearchLoaderId.DATABASE: 319 return mSearchFeatureProvider.getDatabaseSearchLoader(activity, mQuery); 320 case SearchLoaderId.INSTALLED_APPS: 321 return mSearchFeatureProvider.getInstalledAppSearchLoader(activity, mQuery); 322 case SearchLoaderId.ACCESSIBILITY_SERVICES: 323 return mSearchFeatureProvider.getAccessibilityServiceResultLoader(activity, mQuery); 324 case SearchLoaderId.INPUT_DEVICES: 325 return mSearchFeatureProvider.getInputDeviceResultLoader(activity, mQuery); 326 default: 327 return null; 328 } 329 } 330 331 @Override 332 public void onLoadFinished(Loader<Set<? extends SearchResult>> loader, 333 Set<? extends SearchResult> data) { 334 mSearchAdapter.addSearchResults(data, loader.getClass().getName()); 335 if (mUnfinishedLoadersCount.decrementAndGet() != 0) { 336 return; 337 } 338 339 mSearchAdapter.notifyResultsLoaded(); 340 } 341 342 @Override 343 public void onLoaderReset(Loader<Set<? extends SearchResult>> loader) { 344 } 345 346 /** 347 * Gets called when Indexing is completed. 348 */ 349 @Override 350 public void onIndexingFinished() { 351 if (getActivity() == null) { 352 return; 353 } 354 if (mShowingSavedQuery) { 355 mSavedQueryController.loadSavedQueries(); 356 } else { 357 final LoaderManager loaderManager = getLoaderManager(); 358 loaderManager.initLoader(SearchLoaderId.DATABASE, null /* args */, this /* callback */); 359 loaderManager.initLoader( 360 SearchLoaderId.INSTALLED_APPS, null /* args */, this /* callback */); 361 loaderManager.initLoader( 362 SearchLoaderId.ACCESSIBILITY_SERVICES, null /* args */, this /* callback */); 363 loaderManager.initLoader( 364 SearchLoaderId.INPUT_DEVICES, null /* args */, this /* callback */); 365 } 366 367 requery(); 368 } 369 370 public void onSearchResultClicked(SearchViewHolder resultViewHolder, SearchResult result, 371 Pair<Integer, Object>... logTaggedData) { 372 logSearchResultClicked(resultViewHolder, result, logTaggedData); 373 mSearchFeatureProvider.searchResultClicked(getContext(), mQuery, result); 374 mSavedQueryController.saveQuery(mQuery); 375 mResultClickCount++; 376 } 377 378 public void onSearchResultsDisplayed(int resultCount) { 379 if (resultCount == 0) { 380 mNoResultsView.setVisibility(View.VISIBLE); 381 mMetricsFeatureProvider.visible(getContext(), getMetricsCategory(), 382 MetricsEvent.SETTINGS_SEARCH_NO_RESULT); 383 } else { 384 mNoResultsView.setVisibility(View.GONE); 385 mResultsRecyclerView.scrollToPosition(0); 386 } 387 mSearchFeatureProvider.showFeedbackButton(this, getView()); 388 } 389 390 public void onSavedQueryClicked(CharSequence query) { 391 final String queryString = query.toString(); 392 mMetricsFeatureProvider.action(getContext(), 393 MetricsEvent.ACTION_CLICK_SETTINGS_SEARCH_SAVED_QUERY); 394 mSearchView.setQuery(queryString, false /* submit */); 395 onQueryTextChange(queryString); 396 } 397 398 private void restartLoaders() { 399 mShowingSavedQuery = false; 400 final LoaderManager loaderManager = getLoaderManager(); 401 mUnfinishedLoadersCount.set(NUM_QUERY_LOADERS); 402 loaderManager.restartLoader( 403 SearchLoaderId.DATABASE, null /* args */, this /* callback */); 404 loaderManager.restartLoader( 405 SearchLoaderId.INSTALLED_APPS, null /* args */, this /* callback */); 406 loaderManager.restartLoader( 407 SearchLoaderId.ACCESSIBILITY_SERVICES, null /* args */, this /* callback */); 408 loaderManager.restartLoader( 409 SearchLoaderId.INPUT_DEVICES, null /* args */, this /* callback */); 410 } 411 412 public String getQuery() { 413 return mQuery; 414 } 415 416 public List<SearchResult> getSearchResults() { 417 return mSearchAdapter.getSearchResults(); 418 } 419 420 private void requery() { 421 if (TextUtils.isEmpty(mQuery)) { 422 return; 423 } 424 final String query = mQuery; 425 mQuery = ""; 426 onQueryTextChange(query); 427 } 428 429 private void hideKeyboard() { 430 final Activity activity = getActivity(); 431 if (activity != null) { 432 View view = activity.getCurrentFocus(); 433 InputMethodManager imm = (InputMethodManager) 434 activity.getSystemService(Context.INPUT_METHOD_SERVICE); 435 imm.hideSoftInputFromWindow(view.getWindowToken(), 0); 436 } 437 438 if (mResultsRecyclerView != null) { 439 mResultsRecyclerView.requestFocus(); 440 } 441 } 442 443 private void logSearchResultClicked(SearchViewHolder resultViewHolder, SearchResult result, 444 Pair<Integer, Object>... logTaggedData) { 445 final Intent intent = result.payload.getIntent(); 446 if (intent == null) { 447 Log.w(TAG, "Skipped logging click on search result because of null intent, which can " + 448 "happen on saved query results."); 449 return; 450 } 451 final ComponentName cn = intent.getComponent(); 452 String resultName = intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT); 453 if (TextUtils.isEmpty(resultName) && cn != null) { 454 resultName = cn.flattenToString(); 455 } 456 final List<Pair<Integer, Object>> taggedData = new ArrayList<>(); 457 if (logTaggedData != null) { 458 taggedData.addAll(Arrays.asList(logTaggedData)); 459 } 460 taggedData.add(Pair.create( 461 MetricsEvent.FIELD_SETTINGS_SEARCH_RESULT_COUNT, 462 mSearchAdapter.getItemCount())); 463 taggedData.add(Pair.create( 464 MetricsEvent.FIELD_SETTINGS_SEARCH_RESULT_RANK, 465 resultViewHolder.getAdapterPosition())); 466 taggedData.add(Pair.create( 467 MetricsEvent.FIELD_SETTINGS_SEARCH_RESULT_ASYNC_RANKING_STATE, 468 mSearchAdapter.getAsyncRankingState())); 469 taggedData.add(Pair.create( 470 MetricsEvent.FIELD_SETTINGS_SEARCH_QUERY_LENGTH, 471 TextUtils.isEmpty(mQuery) ? 0 : mQuery.length())); 472 473 mMetricsFeatureProvider.action(getContext(), 474 resultViewHolder.getClickActionMetricName(), 475 resultName, 476 taggedData.toArray(new Pair[0])); 477 } 478 } 479