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