/* * 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" } }