• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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