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