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