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