1 /* 2 * Copyright (C) 2013 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 package com.android.documentsui.queries; 18 19 import static com.android.documentsui.base.SharedMinimal.DEBUG; 20 import static com.android.documentsui.base.State.ACTION_GET_CONTENT; 21 import static com.android.documentsui.base.State.ACTION_OPEN; 22 import static com.android.documentsui.base.State.ActionType; 23 24 import android.content.Intent; 25 import android.os.Bundle; 26 import android.os.Handler; 27 import android.os.Looper; 28 import android.provider.DocumentsContract; 29 import android.text.TextUtils; 30 import android.util.Log; 31 import android.view.Menu; 32 import android.view.MenuItem; 33 import android.view.MenuItem.OnActionExpandListener; 34 import android.view.View; 35 import android.view.View.OnClickListener; 36 import android.view.View.OnFocusChangeListener; 37 import android.view.ViewGroup; 38 39 import androidx.annotation.GuardedBy; 40 import androidx.annotation.Nullable; 41 import androidx.annotation.VisibleForTesting; 42 import androidx.appcompat.widget.SearchView; 43 import androidx.appcompat.widget.SearchView.OnQueryTextListener; 44 45 import com.android.documentsui.MetricConsts; 46 import com.android.documentsui.Metrics; 47 import com.android.documentsui.R; 48 import com.android.documentsui.base.DocumentInfo; 49 import com.android.documentsui.base.DocumentStack; 50 import com.android.documentsui.base.EventHandler; 51 import com.android.documentsui.base.RootInfo; 52 import com.android.documentsui.base.Shared; 53 import com.android.documentsui.base.State; 54 55 import java.util.Timer; 56 import java.util.TimerTask; 57 58 /** 59 * Manages searching UI behavior. 60 */ 61 public class SearchViewManager implements 62 SearchView.OnCloseListener, OnQueryTextListener, OnClickListener, OnFocusChangeListener, 63 OnActionExpandListener { 64 65 private static final String TAG = "SearchManager"; 66 67 // How long we wait after the user finishes typing before kicking off a search. 68 public static final int SEARCH_DELAY_MS = 750; 69 70 private final SearchManagerListener mListener; 71 private final EventHandler<String> mCommandProcessor; 72 private final SearchChipViewManager mChipViewManager; 73 private final Timer mTimer; 74 private final Handler mUiHandler; 75 76 private final Object mSearchLock; 77 @GuardedBy("mSearchLock") 78 private @Nullable Runnable mQueuedSearchRunnable; 79 @GuardedBy("mSearchLock") 80 private @Nullable TimerTask mQueuedSearchTask; 81 private @Nullable String mCurrentSearch; 82 private String mQueryContentFromIntent; 83 private boolean mSearchExpanded; 84 private boolean mIgnoreNextClose; 85 private boolean mFullBar; 86 private boolean mIsHistorySearch; 87 private boolean mShowSearchBar; 88 89 private Menu mMenu; 90 private MenuItem mMenuItem; 91 private SearchView mSearchView; 92 SearchViewManager( SearchManagerListener listener, EventHandler<String> commandProcessor, ViewGroup chipGroup, @Nullable Bundle savedState)93 public SearchViewManager( 94 SearchManagerListener listener, 95 EventHandler<String> commandProcessor, 96 ViewGroup chipGroup, 97 @Nullable Bundle savedState) { 98 this(listener, commandProcessor, new SearchChipViewManager(chipGroup), savedState, 99 new Timer(), new Handler(Looper.getMainLooper())); 100 } 101 102 @VisibleForTesting SearchViewManager( SearchManagerListener listener, EventHandler<String> commandProcessor, SearchChipViewManager chipViewManager, @Nullable Bundle savedState, Timer timer, Handler handler)103 protected SearchViewManager( 104 SearchManagerListener listener, 105 EventHandler<String> commandProcessor, 106 SearchChipViewManager chipViewManager, 107 @Nullable Bundle savedState, 108 Timer timer, 109 Handler handler) { 110 assert (listener != null); 111 assert (commandProcessor != null); 112 113 mSearchLock = new Object(); 114 mListener = listener; 115 mCommandProcessor = commandProcessor; 116 mTimer = timer; 117 mUiHandler = handler; 118 mChipViewManager = chipViewManager; 119 mChipViewManager.setSearchChipViewManagerListener(this::onChipCheckedStateChanged); 120 121 if (savedState != null) { 122 mCurrentSearch = savedState.getString(Shared.EXTRA_QUERY); 123 mChipViewManager.restoreCheckedChipItems(savedState); 124 } else { 125 mCurrentSearch = null; 126 } 127 } 128 onChipCheckedStateChanged(View v)129 private void onChipCheckedStateChanged(View v) { 130 mListener.onSearchChipStateChanged(v); 131 performSearch(mCurrentSearch); 132 } 133 134 /** 135 * Parse the query content from Intent. If the action is not {@link State#ACTION_GET_CONTENT} 136 * or {@link State#ACTION_OPEN}, don't perform search. 137 * @param intent the intent to parse. 138 * @param action the action to check. 139 * @return True, if get the query content from the intent. Otherwise, false. 140 */ parseQueryContentFromIntent(Intent intent, @ActionType int action)141 public boolean parseQueryContentFromIntent(Intent intent, @ActionType int action) { 142 if (action == ACTION_OPEN || action == ACTION_GET_CONTENT) { 143 final String queryString = intent.getStringExtra(Intent.EXTRA_CONTENT_QUERY); 144 if (!TextUtils.isEmpty(queryString)) { 145 mQueryContentFromIntent = queryString; 146 return true; 147 } 148 } 149 return false; 150 } 151 152 /** 153 * Build the bundle of query arguments. 154 * Example: search string and mime types 155 * 156 * @return the bundle of query arguments 157 */ buildQueryArgs()158 public Bundle buildQueryArgs() { 159 final Bundle queryArgs = new Bundle(); 160 if (!TextUtils.isEmpty(mCurrentSearch)) { 161 queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mCurrentSearch); 162 } 163 164 final String[] checkedMimeTypes = mChipViewManager.getCheckedMimeTypes(); 165 if (checkedMimeTypes != null && checkedMimeTypes.length > 0) { 166 queryArgs.putStringArray(DocumentsContract.QUERY_ARG_MIME_TYPES, checkedMimeTypes); 167 } 168 return queryArgs; 169 } 170 171 /** 172 * Initialize the search chips base on the acceptMimeTypes. 173 * 174 * @param acceptMimeTypes use to filter chips 175 */ initChipSets(String[] acceptMimeTypes)176 public void initChipSets(String[] acceptMimeTypes) { 177 mChipViewManager.initChipSets(acceptMimeTypes); 178 } 179 180 /** 181 * Update the search chips base on the acceptMimeTypes. 182 * If the count of matched chips is less than two, we will 183 * hide the chip row. 184 * 185 * @param acceptMimeTypes use to filter chips 186 */ updateChips(String[] acceptMimeTypes)187 public void updateChips(String[] acceptMimeTypes) { 188 mChipViewManager.updateChips(acceptMimeTypes); 189 } 190 191 /** 192 * Bind chip data in ChipViewManager on other view groups 193 * 194 * @param chipGroup target view group for bind ChipViewManager data 195 */ bindChips(ViewGroup chipGroup)196 public void bindChips(ViewGroup chipGroup) { 197 mChipViewManager.bindMirrorGroup(chipGroup); 198 } 199 200 /** 201 * Click behavior when chip in synced chip group click. 202 * 203 * @param data SearchChipData synced in mirror group 204 */ onMirrorChipClick(SearchChipData data)205 public void onMirrorChipClick(SearchChipData data) { 206 mChipViewManager.onMirrorChipClick(data); 207 mSearchView.clearFocus(); 208 } 209 210 /** 211 * Initailize search view by option menu. 212 * 213 * @param menu the menu include search view 214 * @param isFullBarSearch whether hide other menu when search view expand 215 * @param isShowSearchBar whether replace collapsed search view by search hint text 216 */ install(Menu menu, boolean isFullBarSearch, boolean isShowSearchBar)217 public void install(Menu menu, boolean isFullBarSearch, boolean isShowSearchBar) { 218 mMenu = menu; 219 mMenuItem = mMenu.findItem(R.id.option_menu_search); 220 mSearchView = (SearchView) mMenuItem.getActionView(); 221 222 mSearchView.setOnQueryTextListener(this); 223 mSearchView.setOnCloseListener(this); 224 mSearchView.setOnSearchClickListener(this); 225 mSearchView.setOnQueryTextFocusChangeListener(this); 226 final View clearButton = mSearchView.findViewById(R.id.search_close_btn); 227 if (clearButton != null) { 228 clearButton.setOnClickListener(v -> { 229 mSearchView.setQuery("", false); 230 mListener.onSearchViewClearClicked(); 231 }); 232 } 233 234 mFullBar = isFullBarSearch; 235 mShowSearchBar = isShowSearchBar; 236 mSearchView.setMaxWidth(Integer.MAX_VALUE); 237 mMenuItem.setOnActionExpandListener(this); 238 239 restoreSearch(false); 240 } 241 242 /** 243 * Used to hide menu icons, when the search is being restored. Needed because search restoration 244 * is done before onPrepareOptionsMenu(Menu menu) that is overriding the icons visibility. 245 */ updateMenu()246 public void updateMenu() { 247 if (isExpanded() && mFullBar) { 248 mMenu.setGroupVisible(R.id.group_hide_when_searching, false); 249 } 250 } 251 252 /** 253 * @param stack New stack. 254 */ update(DocumentStack stack)255 public void update(DocumentStack stack) { 256 if (mMenuItem == null) { 257 if (DEBUG) { 258 Log.d(TAG, "update called before Search MenuItem installed."); 259 } 260 return; 261 } 262 263 if (mCurrentSearch != null) { 264 mMenuItem.expandActionView(); 265 266 mSearchView.setIconified(false); 267 mSearchView.clearFocus(); 268 mSearchView.setQuery(mCurrentSearch, false); 269 } else { 270 mSearchView.clearFocus(); 271 if (!mSearchView.isIconified()) { 272 mIgnoreNextClose = true; 273 mSearchView.setIconified(true); 274 } 275 276 if (mMenuItem.isActionViewExpanded()) { 277 mMenuItem.collapseActionView(); 278 } 279 } 280 281 showMenu(stack); 282 } 283 showMenu(@ullable DocumentStack stack)284 public void showMenu(@Nullable DocumentStack stack) { 285 final DocumentInfo cwd = stack != null ? stack.peek() : null; 286 287 boolean supportsSearch = true; 288 289 // Searching in archives is not enabled, as archives are backed by 290 // a different provider than the root provider. 291 if (cwd != null && cwd.isInArchive()) { 292 supportsSearch = false; 293 } 294 295 final RootInfo root = stack != null ? stack.getRoot() : null; 296 if (root == null || !root.supportsSearch()) { 297 supportsSearch = false; 298 } 299 300 if (mMenuItem == null) { 301 if (DEBUG) { 302 Log.d(TAG, "showMenu called before Search MenuItem installed."); 303 } 304 return; 305 } 306 307 if (!supportsSearch) { 308 mCurrentSearch = null; 309 } 310 311 // Recent root show open search bar, do not show duplicate search icon. 312 mMenuItem.setVisible(supportsSearch && (!stack.isRecents() || !mShowSearchBar)); 313 314 mChipViewManager.setChipsRowVisible(supportsSearch && root.supportsMimeTypesSearch()); 315 } 316 317 /** 318 * Cancels current search operation. Triggers clearing and collapsing the SearchView. 319 * 320 * @return True if it cancels search. False if it does not operate search currently. 321 */ cancelSearch()322 public boolean cancelSearch() { 323 if (mSearchView != null && (isExpanded() || isSearching())) { 324 cancelQueuedSearch(); 325 // If the query string is not empty search view won't get iconified 326 mSearchView.setQuery("", false); 327 328 if (mFullBar) { 329 onClose(); 330 } else { 331 // Causes calling onClose(). onClose() is triggering directory content update. 332 mSearchView.setIconified(true); 333 } 334 335 return true; 336 } 337 return false; 338 } 339 cancelQueuedSearch()340 private void cancelQueuedSearch() { 341 synchronized (mSearchLock) { 342 if (mQueuedSearchTask != null) { 343 mQueuedSearchTask.cancel(); 344 } 345 mQueuedSearchTask = null; 346 mUiHandler.removeCallbacks(mQueuedSearchRunnable); 347 mQueuedSearchRunnable = null; 348 mIsHistorySearch = false; 349 } 350 } 351 352 /** 353 * Sets search view into the searching state. Used to restore state after device orientation 354 * change. 355 */ restoreSearch(boolean keepFocus)356 public void restoreSearch(boolean keepFocus) { 357 if (isTextSearching()) { 358 onSearchBarClicked(); 359 mSearchView.setQuery(mCurrentSearch, false); 360 361 if (keepFocus) { 362 mSearchView.requestFocus(); 363 } else { 364 mSearchView.clearFocus(); 365 } 366 } 367 } 368 onSearchBarClicked()369 public void onSearchBarClicked() { 370 mMenuItem.expandActionView(); 371 onSearchExpanded(); 372 } 373 onSearchExpanded()374 private void onSearchExpanded() { 375 mSearchExpanded = true; 376 if (mFullBar) { 377 mMenu.setGroupVisible(R.id.group_hide_when_searching, false); 378 } 379 380 mListener.onSearchViewChanged(true); 381 } 382 383 /** 384 * Clears the search. Triggers refreshing of the directory content. 385 * 386 * @return True if the default behavior of clearing/dismissing SearchView should be overridden. 387 * False otherwise. 388 */ 389 @Override onClose()390 public boolean onClose() { 391 mSearchExpanded = false; 392 if (mIgnoreNextClose) { 393 mIgnoreNextClose = false; 394 return false; 395 } 396 397 // Refresh the directory if a search was done 398 if (mCurrentSearch != null || mChipViewManager.hasCheckedItems()) { 399 // Clear checked chips 400 mChipViewManager.clearCheckedChips(); 401 mCurrentSearch = null; 402 mListener.onSearchChanged(mCurrentSearch); 403 } 404 405 if (mFullBar) { 406 mMenuItem.collapseActionView(); 407 } 408 mListener.onSearchFinished(); 409 410 mListener.onSearchViewChanged(false); 411 412 return false; 413 } 414 415 /** 416 * Called when owning activity is saving state to be used to restore state during creation. 417 * 418 * @param state Bundle to save state too 419 */ onSaveInstanceState(Bundle state)420 public void onSaveInstanceState(Bundle state) { 421 state.putString(Shared.EXTRA_QUERY, mCurrentSearch); 422 mChipViewManager.onSaveInstanceState(state); 423 } 424 425 /** 426 * Sets mSearchExpanded. Called when search icon is clicked to start search for both search view 427 * modes. 428 */ 429 @Override onClick(View v)430 public void onClick(View v) { 431 onSearchExpanded(); 432 } 433 434 @Override onQueryTextSubmit(String query)435 public boolean onQueryTextSubmit(String query) { 436 437 if (mCommandProcessor.accept(query)) { 438 mSearchView.setQuery("", false); 439 } else { 440 cancelQueuedSearch(); 441 // Don't kick off a search if we've already finished it. 442 if (!TextUtils.equals(mCurrentSearch, query)) { 443 mCurrentSearch = query; 444 mListener.onSearchChanged(mCurrentSearch); 445 } 446 recordHistory(); 447 mSearchView.clearFocus(); 448 } 449 450 return true; 451 } 452 453 /** 454 * Used to detect and handle back button pressed event when search is expanded. 455 */ 456 @Override onFocusChange(View v, boolean hasFocus)457 public void onFocusChange(View v, boolean hasFocus) { 458 if (!hasFocus && !mChipViewManager.hasCheckedItems()) { 459 if (mCurrentSearch == null) { 460 mSearchView.setIconified(true); 461 } else if (TextUtils.isEmpty(mSearchView.getQuery())) { 462 cancelSearch(); 463 } 464 } 465 mListener.onSearchViewFocusChanged(hasFocus); 466 } 467 468 @VisibleForTesting createSearchTask(String newText)469 protected TimerTask createSearchTask(String newText) { 470 return new TimerTask() { 471 @Override 472 public void run() { 473 // Do the actual work on the main looper. 474 synchronized (mSearchLock) { 475 mQueuedSearchRunnable = () -> { 476 mCurrentSearch = newText; 477 if (mCurrentSearch != null && mCurrentSearch.isEmpty()) { 478 mCurrentSearch = null; 479 } 480 logTextSearchMetric(); 481 mListener.onSearchChanged(mCurrentSearch); 482 }; 483 mUiHandler.post(mQueuedSearchRunnable); 484 } 485 } 486 }; 487 } 488 489 @Override 490 public boolean onQueryTextChange(String newText) { 491 //Skip first search when search expanded 492 if (!(mCurrentSearch == null && newText.isEmpty())) { 493 performSearch(newText); 494 } 495 return true; 496 } 497 498 private void performSearch(String newText) { 499 cancelQueuedSearch(); 500 synchronized (mSearchLock) { 501 mQueuedSearchTask = createSearchTask(newText); 502 503 mTimer.schedule(mQueuedSearchTask, SEARCH_DELAY_MS); 504 } 505 } 506 507 @Override 508 public boolean onMenuItemActionCollapse(MenuItem item) { 509 mMenu.setGroupVisible(R.id.group_hide_when_searching, true); 510 511 // Handles case when search view is collapsed by using the arrow on the left of the bar 512 if (isExpanded() || isSearching()) { 513 cancelSearch(); 514 return false; 515 } 516 return true; 517 } 518 519 @Override 520 public boolean onMenuItemActionExpand(MenuItem item) { 521 return true; 522 } 523 524 public String getCurrentSearch() { 525 return mCurrentSearch; 526 } 527 528 /** 529 * Get current text on search view. 530 * 531 * @return Cuttent string on search view 532 */ 533 public String getSearchViewText() { 534 return mSearchView.getQuery().toString(); 535 } 536 537 /** 538 * Record current search for history. 539 */ 540 public void recordHistory() { 541 SearchHistoryManager.getInstance( 542 mSearchView.getContext().getApplicationContext()).addHistory(mCurrentSearch); 543 } 544 545 /** 546 * Remove specific text item in history list. 547 * 548 * @param history target string for removed. 549 */ 550 public void removeHistory(String history) { 551 SearchHistoryManager.getInstance( 552 mSearchView.getContext().getApplicationContext()).deleteHistory(history); 553 } 554 555 private void logTextSearchMetric() { 556 if (isTextSearching()) { 557 Metrics.logUserAction(mIsHistorySearch 558 ? MetricConsts.USER_ACTION_SEARCH_HISTORY : MetricConsts.USER_ACTION_SEARCH); 559 Metrics.logSearchType(mIsHistorySearch 560 ? MetricConsts.TYPE_SEARCH_HISTORY : MetricConsts.TYPE_SEARCH_STRING); 561 mIsHistorySearch = false; 562 } 563 } 564 565 /** 566 * Get the query content from intent. 567 * @return If has query content, return the query content. Otherwise, return null 568 * @see #parseQueryContentFromIntent(Intent, int) 569 */ 570 public String getQueryContentFromIntent() { 571 return mQueryContentFromIntent; 572 } 573 574 public void setCurrentSearch(String queryString) { 575 mCurrentSearch = queryString; 576 } 577 578 /** 579 * Set next search type is history search. 580 */ 581 public void setHistorySearch() { 582 mIsHistorySearch = true; 583 } 584 585 public boolean isSearching() { 586 return mCurrentSearch != null || mChipViewManager.hasCheckedItems(); 587 } 588 589 private boolean isTextSearching() { 590 return mCurrentSearch != null; 591 } 592 593 public boolean hasCheckedChip() { 594 return mChipViewManager.hasCheckedItems(); 595 } 596 597 public boolean isExpanded() { 598 return mSearchExpanded; 599 } 600 601 public interface SearchManagerListener { 602 void onSearchChanged(@Nullable String query); 603 604 void onSearchFinished(); 605 606 void onSearchViewChanged(boolean opened); 607 608 void onSearchChipStateChanged(View v); 609 610 void onSearchViewFocusChanged(boolean hasFocus); 611 612 /** 613 * Call back when search view clear button clicked 614 */ 615 void onSearchViewClearClicked(); 616 } 617 } 618