1 /* 2 * Copyright (C) 2022 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.quicksearchbox.ui 18 19 import android.content.Context 20 import android.database.DataSetObserver 21 import android.graphics.drawable.Drawable 22 import android.text.Editable 23 import android.text.TextUtils 24 import android.text.TextWatcher 25 import android.util.AttributeSet 26 import android.util.Log 27 import android.view.KeyEvent 28 import android.view.View 29 import android.view.inputmethod.CompletionInfo 30 import android.view.inputmethod.InputMethodManager 31 import android.widget.AbsListView 32 import android.widget.ImageButton 33 import android.widget.ListAdapter 34 import android.widget.RelativeLayout 35 import android.widget.TextView 36 import android.widget.TextView.OnEditorActionListener 37 import com.android.quicksearchbox.* 38 import com.android.quicksearchbox.R 39 import java.util.Arrays 40 import kotlin.collections.ArrayList 41 42 abstract class SearchActivityView : RelativeLayout { 43 @JvmField protected var mQueryTextView: QueryTextView? = null 44 45 // True if the query was empty on the previous call to updateQuery() 46 @JvmField protected var mQueryWasEmpty = true 47 @JvmField protected var mQueryTextEmptyBg: Drawable? = null 48 protected var mQueryTextNotEmptyBg: Drawable? = null 49 @JvmField protected var mSuggestionsView: SuggestionsListView<ListAdapter?>? = null 50 @JvmField protected var mSuggestionsAdapter: SuggestionsAdapter<ListAdapter?>? = null 51 @JvmField protected var mSearchGoButton: ImageButton? = null 52 @JvmField protected var mVoiceSearchButton: ImageButton? = null 53 @JvmField protected var mButtonsKeyListener: ButtonsKeyListener? = null 54 private var mUpdateSuggestions = false 55 private var mQueryListener: QueryListener? = null 56 private var mSearchClickListener: SearchClickListener? = null 57 @JvmField protected var mExitClickListener: View.OnClickListener? = null 58 59 constructor(context: Context?) : super(context) 60 constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) 61 constructor( 62 context: Context?, 63 attrs: AttributeSet?, 64 defStyle: Int 65 ) : super(context, attrs, defStyle) 66 67 @Override onFinishInflatenull68 protected override fun onFinishInflate() { 69 mQueryTextView = findViewById(R.id.search_src_text) as QueryTextView? 70 mSuggestionsView = findViewById(R.id.suggestions) as SuggestionsView? 71 mSuggestionsView!!.setOnScrollListener(InputMethodCloser() as AbsListView.OnScrollListener?) 72 mSuggestionsView!!.setOnKeyListener(SuggestionsViewKeyListener()) 73 mSuggestionsView!!.setOnFocusChangeListener(SuggestListFocusListener()) 74 mSuggestionsAdapter = createSuggestionsAdapter() 75 // TODO: why do we need focus listeners both on the SuggestionsView and the individual 76 // suggestions? 77 mSuggestionsAdapter!!.setOnFocusChangeListener(SuggestListFocusListener()) 78 mSearchGoButton = findViewById(R.id.search_go_btn) as ImageButton? 79 mVoiceSearchButton = findViewById(R.id.search_voice_btn) as ImageButton? 80 mVoiceSearchButton?.setImageDrawable(voiceSearchIcon) 81 mQueryTextView?.addTextChangedListener(SearchTextWatcher()) 82 mQueryTextView?.setOnEditorActionListener(QueryTextEditorActionListener()) 83 mQueryTextView?.setOnFocusChangeListener(QueryTextViewFocusListener()) 84 mQueryTextEmptyBg = mQueryTextView?.getBackground() 85 mSearchGoButton?.setOnClickListener(SearchGoButtonClickListener()) 86 mButtonsKeyListener = ButtonsKeyListener() 87 mSearchGoButton?.setOnKeyListener(mButtonsKeyListener) 88 mVoiceSearchButton?.setOnKeyListener(mButtonsKeyListener) 89 mUpdateSuggestions = true 90 } 91 onResumenull92 abstract fun onResume() 93 abstract fun onStop() 94 fun onPause() { 95 // Override if necessary 96 } 97 startnull98 fun start() { 99 mSuggestionsAdapter?.listAdapter?.registerDataSetObserver(SuggestionsObserver()) 100 mSuggestionsView!!.setSuggestionsAdapter(mSuggestionsAdapter) 101 } 102 destroynull103 fun destroy() { 104 mSuggestionsView!!.setSuggestionsAdapter(null) // closes mSuggestionsAdapter 105 } 106 107 // TODO: Get rid of this. To make it more easily testable, 108 // the SearchActivityView should not depend on QsbApplication. 109 protected val qsbApplication: QsbApplication 110 get() = QsbApplication[getContext()] 111 protected val voiceSearchIcon: Drawable 112 get() = getResources().getDrawable(R.drawable.ic_btn_speak_now, null) 113 protected val voiceSearch: VoiceSearch? 114 get() = qsbApplication.voiceSearch 115 createSuggestionsAdapternull116 protected fun createSuggestionsAdapter(): SuggestionsAdapter<ListAdapter?> { 117 return DelayingSuggestionsAdapter(SuggestionsListAdapter(qsbApplication.suggestionViewFactory)) 118 } 119 setMaxPromotedResultsnull120 @Suppress("UNUSED_PARAMETER") fun setMaxPromotedResults(maxPromoted: Int) {} 121 limitResultsToViewHeightnull122 fun limitResultsToViewHeight() {} 123 setQueryListenernull124 fun setQueryListener(listener: QueryListener?) { 125 mQueryListener = listener 126 } 127 setSearchClickListenernull128 fun setSearchClickListener(listener: SearchClickListener?) { 129 mSearchClickListener = listener 130 } 131 setVoiceSearchButtonClickListenernull132 fun setVoiceSearchButtonClickListener(listener: View.OnClickListener?) { 133 if (mVoiceSearchButton != null) { 134 mVoiceSearchButton?.setOnClickListener(listener) 135 } 136 } 137 setSuggestionClickListenernull138 fun setSuggestionClickListener(listener: SuggestionClickListener?) { 139 mSuggestionsAdapter!!.setSuggestionClickListener(listener) 140 mQueryTextView!!.setCommitCompletionListener( 141 object : QueryTextView.CommitCompletionListener { 142 @Override 143 override fun onCommitCompletion(position: Int) { 144 mSuggestionsAdapter!!.onSuggestionClicked(position.toLong()) 145 } 146 } 147 ) 148 } 149 setExitClickListenernull150 fun setExitClickListener(listener: View.OnClickListener?) { 151 mExitClickListener = listener 152 } 153 154 var suggestions: Suggestions? 155 get() = mSuggestionsAdapter?.suggestions 156 set(suggestions) { 157 suggestions?.acquire() 158 mSuggestionsAdapter?.suggestions = suggestions 159 } 160 val currentSuggestions: SuggestionCursor 161 get() = mSuggestionsAdapter?.suggestions?.getResult() as SuggestionCursor 162 clearSuggestionsnull163 fun clearSuggestions() { 164 mSuggestionsAdapter?.suggestions = null 165 } 166 167 val query: String 168 get() { 169 val q: CharSequence? = mQueryTextView?.getText() 170 return q.toString() 171 } 172 val isQueryEmpty: Boolean 173 get() = TextUtils.isEmpty(query) 174 175 /** Sets the text in the query box. Does not update the suggestions. */ setQuerynull176 fun setQuery(query: String?, selectAll: Boolean) { 177 mUpdateSuggestions = false 178 mQueryTextView?.setText(query) 179 mQueryTextView!!.setTextSelection(selectAll) 180 mUpdateSuggestions = true 181 } 182 183 protected val activity: SearchActivity? 184 get() { 185 val context: Context = getContext() 186 return if (context is SearchActivity) { 187 context 188 } else { 189 null 190 } 191 } 192 hideSuggestionsnull193 fun hideSuggestions() { 194 mSuggestionsView!!.setVisibility(GONE) 195 } 196 showSuggestionsnull197 fun showSuggestions() { 198 mSuggestionsView!!.setVisibility(VISIBLE) 199 } 200 focusQueryTextViewnull201 fun focusQueryTextView() { 202 mQueryTextView?.requestFocus() 203 } 204 updateUinull205 protected fun updateUi(queryEmpty: Boolean = isQueryEmpty) { 206 updateQueryTextView(queryEmpty) 207 updateSearchGoButton(queryEmpty) 208 updateVoiceSearchButton(queryEmpty) 209 } 210 updateQueryTextViewnull211 protected fun updateQueryTextView(queryEmpty: Boolean) { 212 if (queryEmpty) { 213 mQueryTextView?.setBackground(mQueryTextEmptyBg) 214 mQueryTextView?.setHint(null) 215 } else { 216 mQueryTextView?.setBackgroundResource(R.drawable.textfield_search) 217 } 218 } 219 updateSearchGoButtonnull220 private fun updateSearchGoButton(queryEmpty: Boolean) { 221 if (queryEmpty) { 222 mSearchGoButton?.setVisibility(View.GONE) 223 } else { 224 mSearchGoButton?.setVisibility(View.VISIBLE) 225 } 226 } 227 updateVoiceSearchButtonnull228 protected fun updateVoiceSearchButton(queryEmpty: Boolean) { 229 if (shouldShowVoiceSearch(queryEmpty) && voiceSearch!!.shouldShowVoiceSearch()) { 230 mVoiceSearchButton?.setVisibility(View.VISIBLE) 231 mQueryTextView?.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE) 232 } else { 233 mVoiceSearchButton?.setVisibility(View.GONE) 234 mQueryTextView?.setPrivateImeOptions(null) 235 } 236 } 237 shouldShowVoiceSearchnull238 protected fun shouldShowVoiceSearch(queryEmpty: Boolean): Boolean { 239 return queryEmpty 240 } 241 242 /** Hides the input method. */ hideInputMethodnull243 protected fun hideInputMethod() { 244 val imm: InputMethodManager? = 245 getContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 246 if (imm != null) { 247 imm.hideSoftInputFromWindow(getWindowToken(), 0) 248 } 249 } 250 considerHidingInputMethodnull251 abstract fun considerHidingInputMethod() 252 fun showInputMethodForQuery() { 253 mQueryTextView!!.showInputMethod() 254 } 255 256 /** Dismiss the activity if BACK is pressed when the search box is empty. */ 257 @Suppress("Deprecation") 258 @Override dispatchKeyEventPreImenull259 override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { 260 val activity = activity 261 if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK && isQueryEmpty) { 262 val state: KeyEvent.DispatcherState? = getKeyDispatcherState() 263 if (state != null) { 264 if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { 265 state.startTracking(event, this) 266 return true 267 } else if ( 268 event.getAction() == KeyEvent.ACTION_UP && !event.isCanceled() && state.isTracking(event) 269 ) { 270 hideInputMethod() 271 activity.onBackPressed() 272 return true 273 } 274 } 275 } 276 return super.dispatchKeyEventPreIme(event) 277 } 278 279 /** 280 * If the input method is in fullscreen mode, and the selector corpus is All or Web, use the web 281 * search suggestions as completions. 282 */ updateInputMethodSuggestionsnull283 protected fun updateInputMethodSuggestions() { 284 val imm: InputMethodManager? = 285 getContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 286 if (imm == null || !imm.isFullscreenMode()) return 287 val suggestions: Suggestions = mSuggestionsAdapter?.suggestions ?: return 288 val completions: Array<CompletionInfo>? = webSuggestionsToCompletions(suggestions) 289 if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions).toString() + ")") 290 imm.displayCompletions(mQueryTextView, completions) 291 } 292 webSuggestionsToCompletionsnull293 private fun webSuggestionsToCompletions(suggestions: Suggestions): Array<CompletionInfo>? { 294 val cursor = suggestions.getWebResult() ?: return null 295 val count: Int = cursor.count 296 val completions: ArrayList<CompletionInfo> = ArrayList<CompletionInfo>(count) 297 for (i in 0 until count) { 298 cursor.moveTo(i) 299 val text1: String? = cursor.suggestionText1 300 completions.add(CompletionInfo(i.toLong(), i, text1)) 301 } 302 return completions.toArray(arrayOfNulls<CompletionInfo>(completions.size)) 303 } 304 onSuggestionsChangednull305 protected fun onSuggestionsChanged() { 306 updateInputMethodSuggestions() 307 } 308 309 @Suppress("UNUSED_PARAMETER") onSuggestionKeyDownnull310 protected fun onSuggestionKeyDown( 311 adapter: SuggestionsAdapter<*>?, 312 suggestionId: Long, 313 keyCode: Int, 314 event: KeyEvent? 315 ): Boolean { 316 // Treat enter or search as a click 317 return if ( 318 keyCode == KeyEvent.KEYCODE_ENTER || 319 keyCode == KeyEvent.KEYCODE_SEARCH || 320 keyCode == KeyEvent.KEYCODE_DPAD_CENTER 321 ) { 322 if (adapter != null) { 323 adapter.onSuggestionClicked(suggestionId) 324 true 325 } else { 326 false 327 } 328 } else false 329 } 330 onSearchClickednull331 protected fun onSearchClicked(method: Int): Boolean { 332 return if (mSearchClickListener != null) { 333 mSearchClickListener!!.onSearchClicked(method) 334 } else false 335 } 336 337 /** Filters the suggestions list when the search text changes. */ 338 private inner class SearchTextWatcher : TextWatcher { 339 @Override afterTextChangednull340 override fun afterTextChanged(s: Editable) { 341 val empty = s.length == 0 342 if (empty != mQueryWasEmpty) { 343 mQueryWasEmpty = empty 344 updateUi(empty) 345 } 346 if (mUpdateSuggestions) { 347 if (mQueryListener != null) { 348 mQueryListener!!.onQueryChanged() 349 } 350 } 351 } 352 353 @Override beforeTextChangednull354 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 355 onTextChangednull356 @Override override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} 357 } 358 359 /** Handles key events on the suggestions list view. */ 360 protected inner class SuggestionsViewKeyListener : View.OnKeyListener { 361 @Override onKeynull362 override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean { 363 if (event.getAction() == KeyEvent.ACTION_DOWN && v is SuggestionsListView<*>) { 364 val listView = v as SuggestionsListView<*> 365 if ( 366 onSuggestionKeyDown( 367 listView.getSuggestionsAdapter(), 368 listView.getSelectedItemId(), 369 keyCode, 370 event 371 ) 372 ) { 373 return true 374 } 375 } 376 return forwardKeyToQueryTextView(keyCode, event) 377 } 378 } 379 380 private inner class InputMethodCloser : AbsListView.OnScrollListener { 381 @Override onScrollnull382 override fun onScroll( 383 view: AbsListView?, 384 firstVisibleItem: Int, 385 visibleItemCount: Int, 386 totalItemCount: Int 387 ) {} 388 389 @Override onScrollStateChangednull390 override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) { 391 considerHidingInputMethod() 392 } 393 } 394 395 /** Listens for clicks on the source selector. */ 396 private inner class SearchGoButtonClickListener : View.OnClickListener { 397 @Override onClicknull398 override fun onClick(view: View?) { 399 onSearchClicked(Logger.SEARCH_METHOD_BUTTON) 400 } 401 } 402 403 /** This class handles enter key presses in the query text view. */ 404 private inner class QueryTextEditorActionListener : OnEditorActionListener { 405 @Override onEditorActionnull406 override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { 407 var consumed = false 408 if (event != null) { 409 if (event.getAction() == KeyEvent.ACTION_UP) { 410 consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD) 411 } else if (event.getAction() == KeyEvent.ACTION_DOWN) { 412 // we have to consume the down event so that we receive the up event too 413 consumed = true 414 } 415 } 416 if (DBG) Log.d(TAG, "onEditorAction consumed=$consumed") 417 return consumed 418 } 419 } 420 421 /** Handles key events on the search and voice search buttons, by refocusing to EditText. */ 422 protected inner class ButtonsKeyListener : View.OnKeyListener { 423 @Override onKeynull424 override fun onKey(v: View?, keyCode: Int, event: KeyEvent): Boolean { 425 return forwardKeyToQueryTextView(keyCode, event) 426 } 427 } 428 forwardKeyToQueryTextViewnull429 private fun forwardKeyToQueryTextView(keyCode: Int, event: KeyEvent): Boolean { 430 if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) { 431 if (DBG) Log.d(TAG, "Forwarding key to query box: $event") 432 if (mQueryTextView!!.requestFocus()) { 433 return mQueryTextView!!.dispatchKeyEvent(event) 434 } 435 } 436 return false 437 } 438 shouldForwardToQueryTextViewnull439 private fun shouldForwardToQueryTextView(keyCode: Int): Boolean { 440 return when (keyCode) { 441 KeyEvent.KEYCODE_DPAD_UP, 442 KeyEvent.KEYCODE_DPAD_DOWN, 443 KeyEvent.KEYCODE_DPAD_LEFT, 444 KeyEvent.KEYCODE_DPAD_RIGHT, 445 KeyEvent.KEYCODE_DPAD_CENTER, 446 KeyEvent.KEYCODE_ENTER, 447 KeyEvent.KEYCODE_SEARCH -> false 448 else -> true 449 } 450 } 451 452 /** Hides the input method when the suggestions get focus. */ 453 private inner class SuggestListFocusListener : OnFocusChangeListener { 454 @Override onFocusChangenull455 override fun onFocusChange(v: View?, focused: Boolean) { 456 if (DBG) Log.d(TAG, "Suggestions focus change, now: $focused") 457 if (focused) { 458 considerHidingInputMethod() 459 } 460 } 461 } 462 463 private inner class QueryTextViewFocusListener : OnFocusChangeListener { 464 @Override onFocusChangenull465 override fun onFocusChange(v: View?, focused: Boolean) { 466 if (DBG) Log.d(TAG, "Query focus change, now: $focused") 467 if (focused) { 468 // The query box got focus, show the input method 469 showInputMethodForQuery() 470 } 471 } 472 } 473 474 protected inner class SuggestionsObserver : DataSetObserver() { 475 @Override onChangednull476 override fun onChanged() { 477 onSuggestionsChanged() 478 } 479 } 480 481 interface QueryListener { onQueryChangednull482 fun onQueryChanged() 483 } 484 485 interface SearchClickListener { 486 fun onSearchClicked(method: Int): Boolean 487 } 488 489 private inner class CloseClickListener : OnClickListener { 490 @Override onClicknull491 override fun onClick(v: View?) { 492 if (!isQueryEmpty) { 493 mQueryTextView?.setText("") 494 } else { 495 mExitClickListener?.onClick(v) 496 } 497 } 498 } 499 500 companion object { 501 protected const val DBG = false 502 protected const val TAG = "QSB.SearchActivityView" 503 504 // The string used for privateImeOptions to identify to the IME that it should not show 505 // a microphone button since one already exists in the search dialog. 506 // TODO: This should move to android-common or something. 507 private const val IME_OPTION_NO_MICROPHONE = "nm" 508 } 509 } 510