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