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
18 
19 import android.app.Activity
20 import android.app.SearchManager
21 import android.content.Intent
22 import android.net.Uri
23 import android.os.Bundle
24 import android.os.Debug
25 import android.os.Handler
26 import android.os.Looper
27 import android.text.TextUtils
28 import android.util.Log
29 import android.view.Menu
30 import android.view.View
31 import com.android.common.Search
32 import com.android.quicksearchbox.ui.SearchActivityView
33 import com.android.quicksearchbox.ui.SuggestionClickListener
34 import com.android.quicksearchbox.ui.SuggestionsAdapter
35 import com.google.common.annotations.VisibleForTesting
36 import com.google.common.base.CharMatcher
37 import java.io.File
38 
39 /** The main activity for Quick Search Box. Shows the search UI. */
40 class SearchActivity : Activity() {
41   private var mTraceStartUp = false
42 
43   // Measures time from for last onCreate()/onNewIntent() call.
44   private var mStartLatencyTracker: LatencyTracker? = null
45 
46   // Measures time spent inside onCreate()
47   private var mOnCreateTracker: LatencyTracker? = null
48   private var mOnCreateLatency = 0
49 
50   // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
51   private var mStarting = false
52 
53   // True if the user has taken some action, e.g. launching a search, voice search,
54   // or suggestions, since QSB was last started.
55   private var mTookAction = false
56   private var mSearchActivityView: SearchActivityView? = null
57   protected var searchSource: Source? = null
58     private set
59   private var mAppSearchData: Bundle? = null
60   private val mHandler: Handler = Handler(Looper.getMainLooper())
61   private val mUpdateSuggestionsTask: Runnable =
62     object : Runnable {
63       @Override
runnull64       override fun run() {
65         updateSuggestions()
66       }
67     }
68   private val mShowInputMethodTask: Runnable =
69     object : Runnable {
70       @Override
runnull71       override fun run() {
72         mSearchActivityView?.showInputMethodForQuery()
73       }
74     }
75   private var mDestroyListener: OnDestroyListener? = null
76 
77   /** Called when the activity is first created. */
78   @Override
onCreatenull79   override fun onCreate(savedInstanceState: Bundle?) {
80     mTraceStartUp = getIntent().hasExtra(INTENT_EXTRA_TRACE_START_UP)
81     if (mTraceStartUp) {
82       val traceFile: String = File(getDir("traces", 0), "qsb-start.trace").getAbsolutePath()
83       Log.i(TAG, "Writing start-up trace to $traceFile")
84       Debug.startMethodTracing(traceFile)
85     }
86     recordStartTime()
87     if (DBG) Log.d(TAG, "onCreate()")
88     super.onCreate(savedInstanceState)
89 
90     // This forces the HTTP request to check the users domain to be
91     // sent as early as possible.
92     QsbApplication[this].searchBaseUrlHelper
93     searchSource = QsbApplication[this].googleSource
94     mSearchActivityView = setupContentView()
95     if (config?.showScrollingResults() == true) {
96       mSearchActivityView?.setMaxPromotedResults(config!!.maxPromotedResults)
97     } else {
98       mSearchActivityView?.limitResultsToViewHeight()
99     }
100     mSearchActivityView?.setSearchClickListener(
101       object : SearchActivityView.SearchClickListener {
102         @Override
103         override fun onSearchClicked(method: Int): Boolean {
104           return this@SearchActivity.onSearchClicked(method)
105         }
106       }
107     )
108     mSearchActivityView?.setQueryListener(
109       object : SearchActivityView.QueryListener {
110         @Override
111         override fun onQueryChanged() {
112           updateSuggestionsBuffered()
113         }
114       }
115     )
116     mSearchActivityView?.setSuggestionClickListener(ClickHandler())
117     mSearchActivityView?.setVoiceSearchButtonClickListener(
118       object : View.OnClickListener {
119         @Override
120         override fun onClick(view: View?) {
121           onVoiceSearchClicked()
122         }
123       }
124     )
125     val finishOnClick: View.OnClickListener =
126       object : View.OnClickListener {
127         @Override
128         override fun onClick(v: View?) {
129           finish()
130         }
131       }
132     mSearchActivityView?.setExitClickListener(finishOnClick)
133 
134     // First get setup from intent
135     val intent: Intent = getIntent()
136     setupFromIntent(intent)
137     // Then restore any saved instance state
138     restoreInstanceState(savedInstanceState)
139 
140     // Do this at the end, to avoid updating the list view when setSource()
141     // is called.
142     mSearchActivityView?.start()
143     recordOnCreateDone()
144   }
145 
setupContentViewnull146   protected fun setupContentView(): SearchActivityView {
147     setContentView(R.layout.search_activity)
148     return findViewById(R.id.search_activity_view) as SearchActivityView
149   }
150 
151   protected val searchActivityView: SearchActivityView?
152     get() = mSearchActivityView
153 
154   @Override
onNewIntentnull155   protected override fun onNewIntent(intent: Intent) {
156     if (DBG) Log.d(TAG, "onNewIntent()")
157     recordStartTime()
158     setIntent(intent)
159     setupFromIntent(intent)
160   }
161 
recordStartTimenull162   private fun recordStartTime() {
163     mStartLatencyTracker = LatencyTracker()
164     mOnCreateTracker = LatencyTracker()
165     mStarting = true
166     mTookAction = false
167   }
168 
recordOnCreateDonenull169   private fun recordOnCreateDone() {
170     mOnCreateLatency = mOnCreateTracker!!.latency
171   }
172 
restoreInstanceStatenull173   protected fun restoreInstanceState(savedInstanceState: Bundle?) {
174     if (savedInstanceState == null) return
175     val query: String? = savedInstanceState.getString(INSTANCE_KEY_QUERY)
176     setQuery(query, false)
177   }
178 
179   @Override
onSaveInstanceStatenull180   protected override fun onSaveInstanceState(outState: Bundle) {
181     super.onSaveInstanceState(outState)
182     // We don't save appSearchData, since we always get the value
183     // from the intent and the user can't change it.
184     outState.putString(INSTANCE_KEY_QUERY, query)
185   }
186 
setupFromIntentnull187   private fun setupFromIntent(intent: Intent) {
188     if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0).toString() + ")")
189     @Suppress("UNUSED_VARIABLE") val corpusName = getCorpusNameFromUri(intent.getData())
190     val query: String? = intent.getStringExtra(SearchManager.QUERY)
191     val appSearchData: Bundle? = intent.getBundleExtra(SearchManager.APP_DATA)
192     val selectAll: Boolean = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false)
193     setQuery(query, selectAll)
194     mAppSearchData = appSearchData
195   }
196 
getCorpusNameFromUrinull197   private fun getCorpusNameFromUri(uri: Uri?): String? {
198     if (uri == null) return null
199     return if (SCHEME_CORPUS != uri.getScheme()) null else uri.getAuthority()
200   }
201 
202   private val qsbApplication: QsbApplication
203     get() = QsbApplication[this]
204 
205   private val config: Config?
206     get() = qsbApplication.config
207 
208   protected val settings: SearchSettings?
209     get() = qsbApplication.settings
210 
211   private val suggestionsProvider: SuggestionsProvider?
212     get() = qsbApplication.suggestionsProvider
213 
214   private val logger: Logger?
215     get() = qsbApplication.logger
216 
217   @VisibleForTesting
setOnDestroyListenernull218   fun setOnDestroyListener(l: OnDestroyListener?) {
219     mDestroyListener = l
220   }
221 
222   @Override
onDestroynull223   protected override fun onDestroy() {
224     if (DBG) Log.d(TAG, "onDestroy()")
225     mSearchActivityView?.destroy()
226     super.onDestroy()
227     if (mDestroyListener != null) {
228       mDestroyListener?.onDestroyed()
229     }
230   }
231 
232   @Override
onStopnull233   protected override fun onStop() {
234     if (DBG) Log.d(TAG, "onStop()")
235     if (!mTookAction) {
236       // TODO: This gets logged when starting other activities, e.g. by opening the search
237       // settings, or clicking a notification in the status bar.
238       // TODO we should log both sets of suggestions in 2-pane mode
239       logger?.logExit(currentSuggestions, query!!.length)
240     }
241     // Close all open suggestion cursors. The query will be redone in onResume()
242     // if we come back to this activity.
243     mSearchActivityView?.clearSuggestions()
244     mSearchActivityView?.onStop()
245     super.onStop()
246   }
247 
248   @Override
onPausenull249   protected override fun onPause() {
250     if (DBG) Log.d(TAG, "onPause()")
251     mSearchActivityView?.onPause()
252     super.onPause()
253   }
254 
255   @Override
onRestartnull256   protected override fun onRestart() {
257     if (DBG) Log.d(TAG, "onRestart()")
258     super.onRestart()
259   }
260 
261   @Override
onResumenull262   protected override fun onResume() {
263     if (DBG) Log.d(TAG, "onResume()")
264     super.onResume()
265     updateSuggestionsBuffered()
266     mSearchActivityView?.onResume()
267     if (mTraceStartUp) Debug.stopMethodTracing()
268   }
269 
270   @Override
onPrepareOptionsMenunull271   override fun onPrepareOptionsMenu(menu: Menu): Boolean {
272     // Since the menu items are dynamic, we recreate the menu every time.
273     menu.clear()
274     createMenuItems(menu, true)
275     return true
276   }
277 
278   @Suppress("UNUSED_PARAMETER")
createMenuItemsnull279   fun createMenuItems(menu: Menu, showDisabled: Boolean) {
280     qsbApplication.help.addHelpMenuItem(menu, ACTIVITY_HELP_CONTEXT)
281   }
282 
283   @Override
onWindowFocusChangednull284   override fun onWindowFocusChanged(hasFocus: Boolean) {
285     super.onWindowFocusChanged(hasFocus)
286     if (hasFocus) {
287       // Launch the IME after a bit
288       mHandler.postDelayed(mShowInputMethodTask, 0)
289     }
290   }
291 
292   protected val query: String?
293     get() = mSearchActivityView?.query
294 
setQuerynull295   protected fun setQuery(query: String?, selectAll: Boolean) {
296     mSearchActivityView?.setQuery(query, selectAll)
297   }
298 
299   /** @return true if a search was performed as a result of this click, false otherwise. */
onSearchClickednull300   protected fun onSearchClicked(method: Int): Boolean {
301     val query: String = CharMatcher.whitespace().trimAndCollapseFrom(query as CharSequence, ' ')
302     if (DBG) Log.d(TAG, "Search clicked, query=$query")
303 
304     // Don't do empty queries
305     if (TextUtils.getTrimmedLength(query) == 0) return false
306     mTookAction = true
307 
308     // Log search start
309     logger?.logSearch(method, query.length)
310 
311     // Start search
312     startSearch(searchSource, query)
313     return true
314   }
315 
startSearchnull316   protected fun startSearch(searchSource: Source?, query: String?) {
317     val intent: Intent? = searchSource!!.createSearchIntent(query, mAppSearchData)
318     launchIntent(intent)
319   }
320 
onVoiceSearchClickednull321   protected fun onVoiceSearchClicked() {
322     if (DBG) Log.d(TAG, "Voice Search clicked")
323     mTookAction = true
324 
325     // Log voice search start
326     logger?.logVoiceSearch()
327 
328     // Start voice search
329     val intent: Intent? = searchSource!!.createVoiceSearchIntent(mAppSearchData)
330     launchIntent(intent)
331   }
332 
333   protected val currentSuggestions: SuggestionCursor?
334     get() {
335       val suggestions: Suggestions = mSearchActivityView?.suggestions ?: return null
336       return suggestions.getResult()
337     }
338 
getCurrentSuggestionsnull339   protected fun getCurrentSuggestions(
340     adapter: SuggestionsAdapter<*>?,
341     id: Long
342   ): SuggestionPosition? {
343     val pos: SuggestionPosition = adapter?.getSuggestion(id) ?: return null
344     val suggestions: SuggestionCursor? = pos.cursor
345     val position: Int = pos.position
346     if (suggestions == null) {
347       return null
348     }
349     val count: Int = suggestions.count
350     if (position < 0 || position >= count) {
351       Log.w(TAG, "Invalid suggestion position $position, count = $count")
352       return null
353     }
354     suggestions.moveTo(position)
355     return pos
356   }
357 
launchIntentnull358   protected fun launchIntent(intent: Intent?) {
359     if (DBG) Log.d(TAG, "launchIntent $intent")
360     if (intent == null) {
361       return
362     }
363     try {
364       startActivity(intent)
365     } catch (ex: RuntimeException) {
366       // Since the intents for suggestions specified by suggestion providers,
367       // guard against them not being handled, not allowed, etc.
368       Log.e(TAG, "Failed to start " + intent.toUri(0), ex)
369     }
370   }
371 
launchSuggestionnull372   private fun launchSuggestion(adapter: SuggestionsAdapter<*>?, id: Long): Boolean {
373     val suggestion = getCurrentSuggestions(adapter, id) ?: return false
374     if (DBG) Log.d(TAG, "Launching suggestion $id")
375     mTookAction = true
376 
377     // Log suggestion click
378     logger?.logSuggestionClick(id, suggestion.cursor, Logger.SUGGESTION_CLICK_TYPE_LAUNCH)
379 
380     // Launch intent
381     launchSuggestion(suggestion.cursor, suggestion.position)
382     return true
383   }
384 
launchSuggestionnull385   protected fun launchSuggestion(suggestions: SuggestionCursor?, position: Int) {
386     suggestions?.moveTo(position)
387     val intent: Intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData)
388     launchIntent(intent)
389   }
390 
refineSuggestionnull391   protected fun refineSuggestion(adapter: SuggestionsAdapter<*>?, id: Long) {
392     if (DBG) Log.d(TAG, "query refine clicked, pos $id")
393     val suggestion = getCurrentSuggestions(adapter, id) ?: return
394     val query: String? = suggestion.suggestionQuery
395     if (TextUtils.isEmpty(query)) {
396       return
397     }
398 
399     // Log refine click
400     logger?.logSuggestionClick(id, suggestion.cursor, Logger.SUGGESTION_CLICK_TYPE_REFINE)
401 
402     // Put query + space in query text view
403     val queryWithSpace = "$query "
404     setQuery(queryWithSpace, false)
405     updateSuggestions()
406     mSearchActivityView?.focusQueryTextView()
407   }
408 
updateSuggestionsBufferednull409   private fun updateSuggestionsBuffered() {
410     if (DBG) Log.d(TAG, "updateSuggestionsBuffered()")
411     mHandler.removeCallbacks(mUpdateSuggestionsTask)
412     val delay: Long = config!!.typingUpdateSuggestionsDelayMillis
413     mHandler.postDelayed(mUpdateSuggestionsTask, delay)
414   }
415 
416   @Suppress("UNUSED_PARAMETER")
gotSuggestionsnull417   private fun gotSuggestions(suggestions: Suggestions?) {
418     if (mStarting) {
419       mStarting = false
420       val source: String? = getIntent().getStringExtra(Search.SOURCE)
421       val latency: Int = mStartLatencyTracker!!.latency
422       logger?.logStart(mOnCreateLatency, latency, source)
423       qsbApplication.onStartupComplete()
424     }
425   }
426 
updateSuggestionsnull427   fun updateSuggestions() {
428     if (DBG) Log.d(TAG, "updateSuggestions()")
429     val query: String = CharMatcher.whitespace().trimLeadingFrom(query as CharSequence)
430     updateSuggestions(query, searchSource)
431   }
432 
updateSuggestionsnull433   protected fun updateSuggestions(query: String, source: Source?) {
434     if (DBG) Log.d(TAG, "updateSuggestions(\"$query\",$source)")
435     val suggestions = suggestionsProvider?.getSuggestions(query, source!!)
436 
437     // Log start latency if this is the first suggestions update
438     gotSuggestions(suggestions)
439     showSuggestions(suggestions)
440   }
441 
showSuggestionsnull442   protected fun showSuggestions(suggestions: Suggestions?) {
443     mSearchActivityView?.suggestions = suggestions
444   }
445 
446   private inner class ClickHandler : SuggestionClickListener {
447     @Override
onSuggestionClickednull448     override fun onSuggestionClicked(adapter: SuggestionsAdapter<*>?, suggestionId: Long) {
449       launchSuggestion(adapter, suggestionId)
450     }
451 
452     @Override
onSuggestionQueryRefineClickednull453     override fun onSuggestionQueryRefineClicked(
454       adapter: SuggestionsAdapter<*>?,
455       suggestionId: Long
456     ) {
457       refineSuggestion(adapter, suggestionId)
458     }
459   }
460 
461   interface OnDestroyListener {
onDestroyednull462     fun onDestroyed()
463   }
464 
465   companion object {
466     private const val DBG = false
467     private const val TAG = "QSB.SearchActivity"
468     private const val SCHEME_CORPUS = "qsb.corpus"
469     private const val INTENT_EXTRA_TRACE_START_UP = "trace_start_up"
470 
471     // Keys for the saved instance state.
472     private const val INSTANCE_KEY_QUERY = "query"
473     private const val ACTIVITY_HELP_CONTEXT = "search"
474   }
475 }
476