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 // If the query string is not empty search view won't get iconified 337 mSearchView.setQuery("", false); 338 339 if (mFullBar) { 340 onClose(); 341 } else { 342 // Causes calling onClose(). onClose() is triggering directory content update. 343 mSearchView.setIconified(true); 344 } 345 346 return true; 347 } 348 return false; 349 } 350 getPixelForDp(int dp)351 private int getPixelForDp(int dp) { 352 final float scale = mSearchView.getContext().getResources().getDisplayMetrics().density; 353 return (int) (dp * scale + 0.5f); 354 } 355 cancelQueuedSearch()356 private void cancelQueuedSearch() { 357 synchronized (mSearchLock) { 358 if (mQueuedSearchTask != null) { 359 mQueuedSearchTask.cancel(); 360 } 361 mQueuedSearchTask = null; 362 mUiHandler.removeCallbacks(mQueuedSearchRunnable); 363 mQueuedSearchRunnable = null; 364 mIsHistorySearch = false; 365 } 366 } 367 368 /** 369 * Sets search view into the searching state. Used to restore state after device orientation 370 * change. 371 */ restoreSearch(boolean keepFocus)372 public void restoreSearch(boolean keepFocus) { 373 if (mSearchView == null) { 374 return; 375 } 376 377 if (isTextSearching()) { 378 onSearchBarClicked(); 379 mSearchView.setQuery(mCurrentSearch, false); 380 381 if (keepFocus) { 382 mSearchView.requestFocus(); 383 } else { 384 mSearchView.clearFocus(); 385 } 386 } 387 } 388 onSearchBarClicked()389 public void onSearchBarClicked() { 390 if (mMenuItem == null) { 391 return; 392 } 393 394 mMenuItem.expandActionView(); 395 onSearchExpanded(); 396 } 397 onSearchExpanded()398 private void onSearchExpanded() { 399 mSearchExpanded = true; 400 if (mFullBar && mMenu != null) { 401 mMenu.setGroupVisible(R.id.group_hide_when_searching, false); 402 } 403 404 mListener.onSearchViewChanged(true); 405 } 406 407 /** 408 * Clears the search. Triggers refreshing of the directory content. 409 * 410 * @return True if the default behavior of clearing/dismissing SearchView should be overridden. 411 * False otherwise. 412 */ 413 @Override onClose()414 public boolean onClose() { 415 mSearchExpanded = false; 416 if (mIgnoreNextClose) { 417 mIgnoreNextClose = false; 418 return false; 419 } 420 421 // Refresh the directory if a search was done 422 if (mCurrentSearch != null || mChipViewManager.hasCheckedItems()) { 423 // Clear checked chips 424 mChipViewManager.clearCheckedChips(); 425 mCurrentSearch = null; 426 mListener.onSearchChanged(mCurrentSearch); 427 } 428 429 if (mFullBar && mMenuItem != null) { 430 mMenuItem.collapseActionView(); 431 } 432 mListener.onSearchFinished(); 433 434 mListener.onSearchViewChanged(false); 435 436 return false; 437 } 438 439 /** 440 * Called when owning activity is saving state to be used to restore state during creation. 441 * 442 * @param state Bundle to save state too 443 */ onSaveInstanceState(Bundle state)444 public void onSaveInstanceState(Bundle state) { 445 if (mSearchView != null && mSearchView.hasFocus() && mCurrentSearch == null) { 446 // Restore focus even if no text was input before screen rotation. 447 mCurrentSearch = ""; 448 } 449 state.putString(Shared.EXTRA_QUERY, mCurrentSearch); 450 mChipViewManager.onSaveInstanceState(state); 451 } 452 453 /** 454 * Sets mSearchExpanded. Called when search icon is clicked to start search for both search view 455 * modes. 456 */ 457 @Override onClick(View v)458 public void onClick(View v) { 459 onSearchExpanded(); 460 } 461 462 @Override onQueryTextSubmit(String query)463 public boolean onQueryTextSubmit(String query) { 464 465 if (mCommandProcessor.accept(query)) { 466 mSearchView.setQuery("", false); 467 } else { 468 cancelQueuedSearch(); 469 // Don't kick off a search if we've already finished it. 470 if (!TextUtils.equals(mCurrentSearch, query)) { 471 mCurrentSearch = query; 472 mListener.onSearchChanged(mCurrentSearch); 473 } 474 recordHistory(); 475 mSearchView.clearFocus(); 476 } 477 478 return true; 479 } 480 481 /** 482 * Used to detect and handle back button pressed event when search is expanded. 483 */ 484 @Override onFocusChange(View v, boolean hasFocus)485 public void onFocusChange(View v, boolean hasFocus) { 486 if (!hasFocus && !mChipViewManager.hasCheckedItems()) { 487 if (mSearchView != null && mCurrentSearch == null) { 488 mSearchView.setIconified(true); 489 } else if (TextUtils.isEmpty(getSearchViewText())) { 490 cancelSearch(); 491 } 492 } 493 mListener.onSearchViewFocusChanged(hasFocus); 494 } 495 496 @VisibleForTesting createSearchTask(String newText)497 protected TimerTask createSearchTask(String newText) { 498 return new TimerTask() { 499 @Override 500 public void run() { 501 // Do the actual work on the main looper. 502 synchronized (mSearchLock) { 503 mQueuedSearchRunnable = () -> { 504 mCurrentSearch = newText; 505 if (mCurrentSearch != null && mCurrentSearch.isEmpty()) { 506 mCurrentSearch = null; 507 } 508 logTextSearchMetric(); 509 mListener.onSearchChanged(mCurrentSearch); 510 }; 511 mUiHandler.post(mQueuedSearchRunnable); 512 } 513 } 514 }; 515 } 516 517 @Override 518 public boolean onQueryTextChange(String newText) { 519 //Skip first search when search expanded 520 if (mCurrentSearch == null && newText.isEmpty()) { 521 return true; 522 } 523 524 performSearch(newText); 525 if (mFragmentManager != null) { 526 if (!newText.isEmpty()) { 527 SearchFragment.dismissFragment(mFragmentManager); 528 } else { 529 SearchFragment.showFragment(mFragmentManager, ""); 530 } 531 } 532 return true; 533 } 534 535 private void performSearch(String newText) { 536 cancelQueuedSearch(); 537 synchronized (mSearchLock) { 538 mQueuedSearchTask = createSearchTask(newText); 539 540 mTimer.schedule(mQueuedSearchTask, SEARCH_DELAY_MS); 541 } 542 } 543 544 @Override 545 public boolean onMenuItemActionCollapse(MenuItem item) { 546 mMenu.setGroupVisible(R.id.group_hide_when_searching, true); 547 548 // Handles case when search view is collapsed by using the arrow on the left of the bar 549 if (isExpanded() || isSearching()) { 550 cancelSearch(); 551 return false; 552 } 553 return true; 554 } 555 556 @Override 557 public boolean onMenuItemActionExpand(MenuItem item) { 558 return true; 559 } 560 561 public String getCurrentSearch() { 562 return mCurrentSearch; 563 } 564 565 /** 566 * Get current text on search view. 567 * 568 * @return Current string on search view 569 */ 570 public String getSearchViewText() { 571 if (mSearchView == null) { 572 return null; 573 } 574 575 return mSearchView.getQuery().toString(); 576 } 577 578 /** 579 * Record current search for history. 580 */ 581 public void recordHistory() { 582 if (TextUtils.isEmpty(mCurrentSearch)) { 583 return; 584 } 585 586 recordHistoryInternal(); 587 } 588 589 protected void recordHistoryInternal() { 590 if (mSearchView == null) { 591 Log.w(TAG, "Search view is null, skip record history this time"); 592 return; 593 } 594 595 SearchHistoryManager.getInstance( 596 mSearchView.getContext().getApplicationContext()).addHistory(mCurrentSearch); 597 } 598 599 /** 600 * Remove specific text item in history list. 601 * 602 * @param history target string for removed. 603 */ 604 public void removeHistory(String history) { 605 if (mSearchView == null) { 606 Log.w(TAG, "Search view is null, skip remove history this time"); 607 return; 608 } 609 610 SearchHistoryManager.getInstance( 611 mSearchView.getContext().getApplicationContext()).deleteHistory(history); 612 } 613 614 private void logTextSearchMetric() { 615 if (isTextSearching()) { 616 Metrics.logUserAction(mIsHistorySearch 617 ? MetricConsts.USER_ACTION_SEARCH_HISTORY : MetricConsts.USER_ACTION_SEARCH); 618 Metrics.logSearchType(mIsHistorySearch 619 ? MetricConsts.TYPE_SEARCH_HISTORY : MetricConsts.TYPE_SEARCH_STRING); 620 mIsHistorySearch = false; 621 } 622 } 623 624 /** 625 * Get the query content from intent. 626 * @return If has query content, return the query content. Otherwise, return null 627 * @see #parseQueryContentFromIntent(Intent, int) 628 */ 629 public String getQueryContentFromIntent() { 630 return mQueryContentFromIntent; 631 } 632 633 public void setCurrentSearch(String queryString) { 634 mCurrentSearch = queryString; 635 } 636 637 /** 638 * Set next search type is history search. 639 */ 640 public void setHistorySearch() { 641 mIsHistorySearch = true; 642 } 643 644 public boolean isSearching() { 645 return mCurrentSearch != null || mChipViewManager.hasCheckedItems(); 646 } 647 648 public boolean isTextSearching() { 649 return mCurrentSearch != null; 650 } 651 652 public boolean hasCheckedChip() { 653 return mChipViewManager.hasCheckedItems(); 654 } 655 656 public boolean isExpanded() { 657 return mSearchExpanded; 658 } 659 660 public interface SearchManagerListener { 661 void onSearchChanged(@Nullable String query); 662 663 void onSearchFinished(); 664 665 void onSearchViewChanged(boolean opened); 666 667 void onSearchChipStateChanged(View v); 668 669 void onSearchViewFocusChanged(boolean hasFocus); 670 671 /** 672 * Call back when search view clear button clicked 673 */ 674 void onSearchViewClearClicked(); 675 } 676 } 677