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 21 import android.annotation.Nullable; 22 import android.os.Bundle; 23 import android.os.Handler; 24 import android.os.Looper; 25 import android.provider.DocumentsContract.Root; 26 import android.text.TextUtils; 27 import android.util.Log; 28 import android.view.Menu; 29 import android.view.MenuItem; 30 import android.view.MenuItem.OnActionExpandListener; 31 import android.view.View; 32 import android.view.View.OnClickListener; 33 import android.view.View.OnFocusChangeListener; 34 import android.widget.SearchView; 35 import android.widget.SearchView.OnQueryTextListener; 36 37 import com.android.documentsui.R; 38 import com.android.documentsui.base.DocumentInfo; 39 import com.android.documentsui.base.DocumentStack; 40 import com.android.documentsui.base.EventHandler; 41 import com.android.documentsui.base.RootInfo; 42 import com.android.documentsui.base.Shared; 43 import com.android.internal.annotations.GuardedBy; 44 import com.android.internal.annotations.VisibleForTesting; 45 46 import java.util.Timer; 47 import java.util.TimerTask; 48 49 /** 50 * Manages searching UI behavior. 51 */ 52 public class SearchViewManager implements 53 SearchView.OnCloseListener, OnQueryTextListener, OnClickListener, OnFocusChangeListener, 54 OnActionExpandListener { 55 56 private static final String TAG = "SearchManager"; 57 58 // How long we wait after the user finishes typing before kicking off a search. 59 public static final int SEARCH_DELAY_MS = 750; 60 61 private final SearchManagerListener mListener; 62 private final EventHandler<String> mCommandProcessor; 63 private final Timer mTimer; 64 private final Handler mUiHandler; 65 66 private final Object mSearchLock; 67 @GuardedBy("mSearchLock") 68 private @Nullable Runnable mQueuedSearchRunnable; 69 @GuardedBy("mSearchLock") 70 private @Nullable TimerTask mQueuedSearchTask; 71 private @Nullable String mCurrentSearch; 72 private boolean mSearchExpanded; 73 private boolean mIgnoreNextClose; 74 private boolean mFullBar; 75 76 private Menu mMenu; 77 private MenuItem mMenuItem; 78 private SearchView mSearchView; 79 SearchViewManager( SearchManagerListener listener, EventHandler<String> commandProcessor, @Nullable Bundle savedState)80 public SearchViewManager( 81 SearchManagerListener listener, 82 EventHandler<String> commandProcessor, 83 @Nullable Bundle savedState) { 84 this(listener, commandProcessor, savedState, new Timer(), 85 new Handler(Looper.getMainLooper())); 86 } 87 88 @VisibleForTesting SearchViewManager( SearchManagerListener listener, EventHandler<String> commandProcessor, @Nullable Bundle savedState, Timer timer, Handler handler)89 protected SearchViewManager( 90 SearchManagerListener listener, 91 EventHandler<String> commandProcessor, 92 @Nullable Bundle savedState, 93 Timer timer, 94 Handler handler) { 95 assert (listener != null); 96 assert (commandProcessor != null); 97 98 mSearchLock = new Object(); 99 mListener = listener; 100 mCommandProcessor = commandProcessor; 101 mTimer = timer; 102 mUiHandler = handler; 103 mCurrentSearch = savedState != null ? savedState.getString(Shared.EXTRA_QUERY) : null; 104 } 105 install(Menu menu, boolean isFullBarSearch)106 public void install(Menu menu, boolean isFullBarSearch) { 107 mMenu = menu; 108 mMenuItem = mMenu.findItem(R.id.option_menu_search); 109 mSearchView = (SearchView) mMenuItem.getActionView(); 110 111 mSearchView.setOnQueryTextListener(this); 112 mSearchView.setOnCloseListener(this); 113 mSearchView.setOnSearchClickListener(this); 114 mSearchView.setOnQueryTextFocusChangeListener(this); 115 116 mFullBar = isFullBarSearch; 117 if (mFullBar) { 118 mMenuItem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW 119 | MenuItem.SHOW_AS_ACTION_ALWAYS); 120 mMenuItem.setOnActionExpandListener(this); 121 mSearchView.setMaxWidth(Integer.MAX_VALUE); 122 } 123 124 restoreSearch(); 125 } 126 127 /** 128 * Used to hide menu icons, when the search is being restored. Needed because search restoration 129 * is done before onPrepareOptionsMenu(Menu menu) that is overriding the icons visibility. 130 */ updateMenu()131 public void updateMenu() { 132 if (isSearching() && mFullBar) { 133 mMenu.setGroupVisible(R.id.group_hide_when_searching, false); 134 } 135 } 136 137 /** 138 * @param stack New stack. 139 */ update(DocumentStack stack)140 public void update(DocumentStack stack) { 141 if (mMenuItem == null) { 142 if (DEBUG) Log.d(TAG, "update called before Search MenuItem installed."); 143 return; 144 } 145 146 if (mCurrentSearch != null) { 147 mMenuItem.expandActionView(); 148 149 mSearchView.setIconified(false); 150 mSearchView.clearFocus(); 151 mSearchView.setQuery(mCurrentSearch, false); 152 } else { 153 mSearchView.clearFocus(); 154 if (!mSearchView.isIconified()) { 155 mIgnoreNextClose = true; 156 mSearchView.setIconified(true); 157 } 158 159 if (mMenuItem.isActionViewExpanded()) { 160 mMenuItem.collapseActionView(); 161 } 162 } 163 164 showMenu(stack); 165 } 166 showMenu(@ullable DocumentStack stack)167 public void showMenu(@Nullable DocumentStack stack) { 168 final DocumentInfo cwd = stack != null ? stack.peek() : null; 169 170 boolean supportsSearch = true; 171 172 // Searching in archives is not enabled, as archives are backed by 173 // a different provider than the root provider. 174 if (cwd != null && cwd.isInArchive()) { 175 supportsSearch = false; 176 } 177 178 final RootInfo root = stack != null ? stack.getRoot() : null; 179 if (root == null || (root.flags & Root.FLAG_SUPPORTS_SEARCH) == 0) { 180 supportsSearch = false; 181 } 182 183 if (mMenuItem == null) { 184 if (DEBUG) Log.d(TAG, "showMenu called before Search MenuItem installed."); 185 return; 186 } 187 188 if (!supportsSearch) { 189 mCurrentSearch = null; 190 } 191 192 mMenuItem.setVisible(supportsSearch); 193 } 194 195 /** 196 * Cancels current search operation. Triggers clearing and collapsing the SearchView. 197 * 198 * @return True if it cancels search. False if it does not operate search currently. 199 */ cancelSearch()200 public boolean cancelSearch() { 201 if (isExpanded() || isSearching()) { 202 cancelQueuedSearch(); 203 // If the query string is not empty search view won't get iconified 204 mSearchView.setQuery("", false); 205 206 if (mFullBar) { 207 onClose(); 208 } else { 209 // Causes calling onClose(). onClose() is triggering directory content update. 210 mSearchView.setIconified(true); 211 } 212 return true; 213 } 214 return false; 215 } 216 cancelQueuedSearch()217 private void cancelQueuedSearch() { 218 synchronized (mSearchLock) { 219 if (mQueuedSearchTask != null) { 220 mQueuedSearchTask.cancel(); 221 } 222 mQueuedSearchTask = null; 223 mUiHandler.removeCallbacks(mQueuedSearchRunnable); 224 mQueuedSearchRunnable = null; 225 } 226 } 227 228 /** 229 * Sets search view into the searching state. Used to restore state after device orientation 230 * change. 231 */ restoreSearch()232 private void restoreSearch() { 233 if (isSearching()) { 234 if (mFullBar) { 235 mMenuItem.expandActionView(); 236 } else { 237 mSearchView.setIconified(false); 238 } 239 onSearchExpanded(); 240 mSearchView.setQuery(mCurrentSearch, false); 241 mSearchView.clearFocus(); 242 } 243 } 244 onSearchExpanded()245 private void onSearchExpanded() { 246 mSearchExpanded = true; 247 if (mFullBar) { 248 mMenu.setGroupVisible(R.id.group_hide_when_searching, false); 249 } 250 251 mListener.onSearchViewChanged(true); 252 } 253 254 /** 255 * Clears the search. Triggers refreshing of the directory content. 256 * @return True if the default behavior of clearing/dismissing SearchView should be overridden. 257 * False otherwise. 258 */ 259 @Override onClose()260 public boolean onClose() { 261 mSearchExpanded = false; 262 if (mIgnoreNextClose) { 263 mIgnoreNextClose = false; 264 return false; 265 } 266 267 // Refresh the directory if a search was done 268 if (mCurrentSearch != null) { 269 mCurrentSearch = null; 270 mListener.onSearchChanged(mCurrentSearch); 271 } 272 273 if (mFullBar) { 274 mMenuItem.collapseActionView(); 275 } 276 mListener.onSearchFinished(); 277 278 mListener.onSearchViewChanged(false); 279 280 return false; 281 } 282 283 /** 284 * Called when owning activity is saving state to be used to restore state during creation. 285 * @param state Bundle to save state too 286 */ onSaveInstanceState(Bundle state)287 public void onSaveInstanceState(Bundle state) { 288 state.putString(Shared.EXTRA_QUERY, mCurrentSearch); 289 } 290 291 /** 292 * Sets mSearchExpanded. Called when search icon is clicked to start search for both search view 293 * modes. 294 */ 295 @Override onClick(View v)296 public void onClick(View v) { 297 onSearchExpanded(); 298 } 299 300 @Override onQueryTextSubmit(String query)301 public boolean onQueryTextSubmit(String query) { 302 303 if (mCommandProcessor.accept(query)) { 304 mSearchView.setQuery("", false); 305 } else { 306 cancelQueuedSearch(); 307 // Don't kick off a search if we've already finished it. 308 if (mCurrentSearch != query) { 309 mCurrentSearch = query; 310 mListener.onSearchChanged(mCurrentSearch); 311 } 312 mSearchView.clearFocus(); 313 } 314 315 return true; 316 } 317 318 /** 319 * Used to detect and handle back button pressed event when search is expanded. 320 */ 321 @Override onFocusChange(View v, boolean hasFocus)322 public void onFocusChange(View v, boolean hasFocus) { 323 if (!hasFocus) { 324 if (mCurrentSearch == null) { 325 mSearchView.setIconified(true); 326 } else if (TextUtils.isEmpty(mSearchView.getQuery())) { 327 cancelSearch(); 328 } 329 } 330 } 331 332 @VisibleForTesting createSearchTask(String newText)333 protected TimerTask createSearchTask(String newText) { 334 return new TimerTask() { 335 @Override 336 public void run() { 337 // Do the actual work on the main looper. 338 synchronized (mSearchLock) { 339 mQueuedSearchRunnable = () -> { 340 mCurrentSearch = newText; 341 if (mCurrentSearch != null && mCurrentSearch.isEmpty()) { 342 mCurrentSearch = null; 343 } 344 mListener.onSearchChanged(mCurrentSearch); 345 }; 346 mUiHandler.post(mQueuedSearchRunnable); 347 } 348 } 349 }; 350 } 351 352 @Override 353 public boolean onQueryTextChange(String newText) { 354 cancelQueuedSearch(); 355 synchronized (mSearchLock) { 356 mQueuedSearchTask = createSearchTask(newText); 357 358 mTimer.schedule(mQueuedSearchTask, SEARCH_DELAY_MS); 359 } 360 return true; 361 } 362 363 @Override 364 public boolean onMenuItemActionCollapse(MenuItem item) { 365 mMenu.setGroupVisible(R.id.group_hide_when_searching, true); 366 367 // Handles case when search view is collapsed by using the arrow on the left of the bar 368 if (isExpanded() || isSearching()) { 369 cancelSearch(); 370 return false; 371 } 372 return true; 373 } 374 375 @Override 376 public boolean onMenuItemActionExpand(MenuItem item) { 377 return true; 378 } 379 380 public String getCurrentSearch() { 381 return mCurrentSearch; 382 } 383 384 public boolean isSearching() { 385 return mCurrentSearch != null; 386 } 387 388 public boolean isExpanded() { 389 return mSearchExpanded; 390 } 391 392 public interface SearchManagerListener { 393 void onSearchChanged(@Nullable String query); 394 void onSearchFinished(); 395 void onSearchViewChanged(boolean opened); 396 } 397 } 398