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