blob: 0620b9728f2a1f5343f57270f1653df86b556b8d [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.quicksearchbox
import android.app.Activity
import android.app.SearchManager
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Debug
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.View
import com.android.common.Search
import com.android.quicksearchbox.ui.SearchActivityView
import com.android.quicksearchbox.ui.SuggestionClickListener
import com.android.quicksearchbox.ui.SuggestionsAdapter
import com.google.common.annotations.VisibleForTesting
import com.google.common.base.CharMatcher
import java.io.File
/** The main activity for Quick Search Box. Shows the search UI. */
class SearchActivity : Activity() {
private var mTraceStartUp = false
// Measures time from for last onCreate()/onNewIntent() call.
private var mStartLatencyTracker: LatencyTracker? = null
// Measures time spent inside onCreate()
private var mOnCreateTracker: LatencyTracker? = null
private var mOnCreateLatency = 0
// Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
private var mStarting = false
// True if the user has taken some action, e.g. launching a search, voice search,
// or suggestions, since QSB was last started.
private var mTookAction = false
private var mSearchActivityView: SearchActivityView? = null
protected var searchSource: Source? = null
private set
private var mAppSearchData: Bundle? = null
private val mHandler: Handler = Handler(Looper.getMainLooper())
private val mUpdateSuggestionsTask: Runnable =
object : Runnable {
@Override
override fun run() {
updateSuggestions()
}
}
private val mShowInputMethodTask: Runnable =
object : Runnable {
@Override
override fun run() {
mSearchActivityView?.showInputMethodForQuery()
}
}
private var mDestroyListener: OnDestroyListener? = null
/** Called when the activity is first created. */
@Override
override fun onCreate(savedInstanceState: Bundle?) {
mTraceStartUp = getIntent().hasExtra(INTENT_EXTRA_TRACE_START_UP)
if (mTraceStartUp) {
val traceFile: String = File(getDir("traces", 0), "qsb-start.trace").getAbsolutePath()
Log.i(TAG, "Writing start-up trace to $traceFile")
Debug.startMethodTracing(traceFile)
}
recordStartTime()
if (DBG) Log.d(TAG, "onCreate()")
super.onCreate(savedInstanceState)
// This forces the HTTP request to check the users domain to be
// sent as early as possible.
QsbApplication[this].searchBaseUrlHelper
searchSource = QsbApplication[this].googleSource
mSearchActivityView = setupContentView()
if (config?.showScrollingResults() == true) {
mSearchActivityView?.setMaxPromotedResults(config!!.maxPromotedResults)
} else {
mSearchActivityView?.limitResultsToViewHeight()
}
mSearchActivityView?.setSearchClickListener(
object : SearchActivityView.SearchClickListener {
@Override
override fun onSearchClicked(method: Int): Boolean {
return this@SearchActivity.onSearchClicked(method)
}
}
)
mSearchActivityView?.setQueryListener(
object : SearchActivityView.QueryListener {
@Override
override fun onQueryChanged() {
updateSuggestionsBuffered()
}
}
)
mSearchActivityView?.setSuggestionClickListener(ClickHandler())
mSearchActivityView?.setVoiceSearchButtonClickListener(
object : View.OnClickListener {
@Override
override fun onClick(view: View?) {
onVoiceSearchClicked()
}
}
)
val finishOnClick: View.OnClickListener =
object : View.OnClickListener {
@Override
override fun onClick(v: View?) {
finish()
}
}
mSearchActivityView?.setExitClickListener(finishOnClick)
// First get setup from intent
val intent: Intent = getIntent()
setupFromIntent(intent)
// Then restore any saved instance state
restoreInstanceState(savedInstanceState)
// Do this at the end, to avoid updating the list view when setSource()
// is called.
mSearchActivityView?.start()
recordOnCreateDone()
}
protected fun setupContentView(): SearchActivityView {
setContentView(R.layout.search_activity)
return findViewById(R.id.search_activity_view) as SearchActivityView
}
protected val searchActivityView: SearchActivityView?
get() = mSearchActivityView
@Override
protected override fun onNewIntent(intent: Intent) {
if (DBG) Log.d(TAG, "onNewIntent()")
recordStartTime()
setIntent(intent)
setupFromIntent(intent)
}
private fun recordStartTime() {
mStartLatencyTracker = LatencyTracker()
mOnCreateTracker = LatencyTracker()
mStarting = true
mTookAction = false
}
private fun recordOnCreateDone() {
mOnCreateLatency = mOnCreateTracker!!.latency
}
protected fun restoreInstanceState(savedInstanceState: Bundle?) {
if (savedInstanceState == null) return
val query: String? = savedInstanceState.getString(INSTANCE_KEY_QUERY)
setQuery(query, false)
}
@Override
protected override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// We don't save appSearchData, since we always get the value
// from the intent and the user can't change it.
outState.putString(INSTANCE_KEY_QUERY, query)
}
private fun setupFromIntent(intent: Intent) {
if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0).toString() + ")")
@Suppress("UNUSED_VARIABLE") val corpusName = getCorpusNameFromUri(intent.getData())
val query: String? = intent.getStringExtra(SearchManager.QUERY)
val appSearchData: Bundle? = intent.getBundleExtra(SearchManager.APP_DATA)
val selectAll: Boolean = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false)
setQuery(query, selectAll)
mAppSearchData = appSearchData
}
private fun getCorpusNameFromUri(uri: Uri?): String? {
if (uri == null) return null
return if (SCHEME_CORPUS != uri.getScheme()) null else uri.getAuthority()
}
private val qsbApplication: QsbApplication
get() = QsbApplication[this]
private val config: Config?
get() = qsbApplication.config
protected val settings: SearchSettings?
get() = qsbApplication.settings
private val suggestionsProvider: SuggestionsProvider?
get() = qsbApplication.suggestionsProvider
private val logger: Logger?
get() = qsbApplication.logger
@VisibleForTesting
fun setOnDestroyListener(l: OnDestroyListener?) {
mDestroyListener = l
}
@Override
protected override fun onDestroy() {
if (DBG) Log.d(TAG, "onDestroy()")
mSearchActivityView?.destroy()
super.onDestroy()
if (mDestroyListener != null) {
mDestroyListener?.onDestroyed()
}
}
@Override
protected override fun onStop() {
if (DBG) Log.d(TAG, "onStop()")
if (!mTookAction) {
// TODO: This gets logged when starting other activities, e.g. by opening the search
// settings, or clicking a notification in the status bar.
// TODO we should log both sets of suggestions in 2-pane mode
logger?.logExit(currentSuggestions, query!!.length)
}
// Close all open suggestion cursors. The query will be redone in onResume()
// if we come back to this activity.
mSearchActivityView?.clearSuggestions()
mSearchActivityView?.onStop()
super.onStop()
}
@Override
protected override fun onPause() {
if (DBG) Log.d(TAG, "onPause()")
mSearchActivityView?.onPause()
super.onPause()
}
@Override
protected override fun onRestart() {
if (DBG) Log.d(TAG, "onRestart()")
super.onRestart()
}
@Override
protected override fun onResume() {
if (DBG) Log.d(TAG, "onResume()")
super.onResume()
updateSuggestionsBuffered()
mSearchActivityView?.onResume()
if (mTraceStartUp) Debug.stopMethodTracing()
}
@Override
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
// Since the menu items are dynamic, we recreate the menu every time.
menu.clear()
createMenuItems(menu, true)
return true
}
@Suppress("UNUSED_PARAMETER")
fun createMenuItems(menu: Menu, showDisabled: Boolean) {
qsbApplication.help.addHelpMenuItem(menu, ACTIVITY_HELP_CONTEXT)
}
@Override
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
// Launch the IME after a bit
mHandler.postDelayed(mShowInputMethodTask, 0)
}
}
protected val query: String?
get() = mSearchActivityView?.query
protected fun setQuery(query: String?, selectAll: Boolean) {
mSearchActivityView?.setQuery(query, selectAll)
}
/** @return true if a search was performed as a result of this click, false otherwise. */
protected fun onSearchClicked(method: Int): Boolean {
val query: String = CharMatcher.whitespace().trimAndCollapseFrom(query as CharSequence, ' ')
if (DBG) Log.d(TAG, "Search clicked, query=$query")
// Don't do empty queries
if (TextUtils.getTrimmedLength(query) == 0) return false
mTookAction = true
// Log search start
logger?.logSearch(method, query.length)
// Start search
startSearch(searchSource, query)
return true
}
protected fun startSearch(searchSource: Source?, query: String?) {
val intent: Intent? = searchSource!!.createSearchIntent(query, mAppSearchData)
launchIntent(intent)
}
protected fun onVoiceSearchClicked() {
if (DBG) Log.d(TAG, "Voice Search clicked")
mTookAction = true
// Log voice search start
logger?.logVoiceSearch()
// Start voice search
val intent: Intent? = searchSource!!.createVoiceSearchIntent(mAppSearchData)
launchIntent(intent)
}
protected val currentSuggestions: SuggestionCursor?
get() {
val suggestions: Suggestions = mSearchActivityView?.suggestions ?: return null
return suggestions.getResult()
}
protected fun getCurrentSuggestions(
adapter: SuggestionsAdapter<*>?,
id: Long
): SuggestionPosition? {
val pos: SuggestionPosition = adapter?.getSuggestion(id) ?: return null
val suggestions: SuggestionCursor? = pos.cursor
val position: Int = pos.position
if (suggestions == null) {
return null
}
val count: Int = suggestions.count
if (position < 0 || position >= count) {
Log.w(TAG, "Invalid suggestion position $position, count = $count")
return null
}
suggestions.moveTo(position)
return pos
}
protected fun launchIntent(intent: Intent?) {
if (DBG) Log.d(TAG, "launchIntent $intent")
if (intent == null) {
return
}
try {
startActivity(intent)
} catch (ex: RuntimeException) {
// Since the intents for suggestions specified by suggestion providers,
// guard against them not being handled, not allowed, etc.
Log.e(TAG, "Failed to start " + intent.toUri(0), ex)
}
}
private fun launchSuggestion(adapter: SuggestionsAdapter<*>?, id: Long): Boolean {
val suggestion = getCurrentSuggestions(adapter, id) ?: return false
if (DBG) Log.d(TAG, "Launching suggestion $id")
mTookAction = true
// Log suggestion click
logger?.logSuggestionClick(id, suggestion.cursor, Logger.SUGGESTION_CLICK_TYPE_LAUNCH)
// Launch intent
launchSuggestion(suggestion.cursor, suggestion.position)
return true
}
protected fun launchSuggestion(suggestions: SuggestionCursor?, position: Int) {
suggestions?.moveTo(position)
val intent: Intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData)
launchIntent(intent)
}
protected fun refineSuggestion(adapter: SuggestionsAdapter<*>?, id: Long) {
if (DBG) Log.d(TAG, "query refine clicked, pos $id")
val suggestion = getCurrentSuggestions(adapter, id) ?: return
val query: String? = suggestion.suggestionQuery
if (TextUtils.isEmpty(query)) {
return
}
// Log refine click
logger?.logSuggestionClick(id, suggestion.cursor, Logger.SUGGESTION_CLICK_TYPE_REFINE)
// Put query + space in query text view
val queryWithSpace = "$query "
setQuery(queryWithSpace, false)
updateSuggestions()
mSearchActivityView?.focusQueryTextView()
}
private fun updateSuggestionsBuffered() {
if (DBG) Log.d(TAG, "updateSuggestionsBuffered()")
mHandler.removeCallbacks(mUpdateSuggestionsTask)
val delay: Long = config!!.typingUpdateSuggestionsDelayMillis
mHandler.postDelayed(mUpdateSuggestionsTask, delay)
}
@Suppress("UNUSED_PARAMETER")
private fun gotSuggestions(suggestions: Suggestions?) {
if (mStarting) {
mStarting = false
val source: String? = getIntent().getStringExtra(Search.SOURCE)
val latency: Int = mStartLatencyTracker!!.latency
logger?.logStart(mOnCreateLatency, latency, source)
qsbApplication.onStartupComplete()
}
}
fun updateSuggestions() {
if (DBG) Log.d(TAG, "updateSuggestions()")
val query: String = CharMatcher.whitespace().trimLeadingFrom(query as CharSequence)
updateSuggestions(query, searchSource)
}
protected fun updateSuggestions(query: String, source: Source?) {
if (DBG) Log.d(TAG, "updateSuggestions(\"$query\",$source)")
val suggestions = suggestionsProvider?.getSuggestions(query, source!!)
// Log start latency if this is the first suggestions update
gotSuggestions(suggestions)
showSuggestions(suggestions)
}
protected fun showSuggestions(suggestions: Suggestions?) {
mSearchActivityView?.suggestions = suggestions
}
private inner class ClickHandler : SuggestionClickListener {
@Override
override fun onSuggestionClicked(adapter: SuggestionsAdapter<*>?, suggestionId: Long) {
launchSuggestion(adapter, suggestionId)
}
@Override
override fun onSuggestionQueryRefineClicked(
adapter: SuggestionsAdapter<*>?,
suggestionId: Long
) {
refineSuggestion(adapter, suggestionId)
}
}
interface OnDestroyListener {
fun onDestroyed()
}
companion object {
private const val DBG = false
private const val TAG = "QSB.SearchActivity"
private const val SCHEME_CORPUS = "qsb.corpus"
private const val INTENT_EXTRA_TRACE_START_UP = "trace_start_up"
// Keys for the saved instance state.
private const val INSTANCE_KEY_QUERY = "query"
private const val ACTIVITY_HELP_CONTEXT = "search"
}
}