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