[fenix] For https://github.com/mozilla-mobile/fenix/issues/24252 - Rename primaryText...
[gecko.git] / mobile / android / fenix / app / src / main / java / org / mozilla / fenix / search / SearchDialogFragment.kt
blob997b655fbfd4a36e55a6c1629e19e3ddb8cbab1e
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.fenix.search
7 import android.Manifest
8 import android.app.Activity
9 import android.app.Dialog
10 import android.content.Context
11 import android.content.DialogInterface
12 import android.content.Intent
13 import android.graphics.Color
14 import android.graphics.Typeface
15 import android.graphics.drawable.ColorDrawable
16 import android.os.Build
17 import android.os.Bundle
18 import android.speech.RecognizerIntent
19 import android.text.style.StyleSpan
20 import android.view.LayoutInflater
21 import android.view.View
22 import android.view.ViewGroup
23 import android.view.ViewStub
24 import android.view.WindowManager
25 import android.view.accessibility.AccessibilityEvent
26 import android.view.inputmethod.InputMethodManager
27 import androidx.appcompat.app.AlertDialog
28 import androidx.appcompat.app.AppCompatDialogFragment
29 import androidx.appcompat.content.res.AppCompatResources
30 import androidx.constraintlayout.widget.ConstraintProperties.BOTTOM
31 import androidx.constraintlayout.widget.ConstraintProperties.PARENT_ID
32 import androidx.constraintlayout.widget.ConstraintProperties.TOP
33 import androidx.constraintlayout.widget.ConstraintSet
34 import androidx.core.net.toUri
35 import androidx.core.view.isVisible
36 import androidx.lifecycle.lifecycleScope
37 import androidx.navigation.fragment.findNavController
38 import androidx.navigation.fragment.navArgs
39 import kotlinx.coroutines.flow.collect
40 import kotlinx.coroutines.flow.map
41 import kotlinx.coroutines.launch
42 import mozilla.components.browser.toolbar.BrowserToolbar
43 import mozilla.components.concept.engine.EngineSession
44 import mozilla.components.concept.storage.HistoryStorage
45 import mozilla.components.feature.qr.QrFeature
46 import mozilla.components.lib.state.ext.consumeFlow
47 import mozilla.components.lib.state.ext.consumeFrom
48 import mozilla.components.support.base.coroutines.Dispatchers
49 import mozilla.components.support.base.feature.UserInteractionHandler
50 import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
51 import mozilla.components.support.ktx.android.content.getColorFromAttr
52 import mozilla.components.support.ktx.android.content.hasCamera
53 import mozilla.components.support.ktx.android.content.isPermissionGranted
54 import mozilla.components.support.ktx.android.content.res.getSpanned
55 import mozilla.components.support.ktx.android.net.isHttpOrHttps
56 import mozilla.components.support.ktx.android.view.hideKeyboard
57 import mozilla.components.support.ktx.kotlin.toNormalizedUrl
58 import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
59 import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
60 import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
61 import org.mozilla.fenix.BrowserDirection
62 import org.mozilla.fenix.HomeActivity
63 import org.mozilla.fenix.R
64 import org.mozilla.fenix.components.metrics.Event
65 import org.mozilla.fenix.components.toolbar.ToolbarPosition
66 import org.mozilla.fenix.databinding.FragmentSearchDialogBinding
67 import org.mozilla.fenix.databinding.SearchSuggestionsHintBinding
68 import org.mozilla.fenix.ext.components
69 import org.mozilla.fenix.ext.requireComponents
70 import org.mozilla.fenix.ext.settings
71 import org.mozilla.fenix.search.awesomebar.AwesomeBarView
72 import org.mozilla.fenix.search.awesomebar.toSearchProviderState
73 import org.mozilla.fenix.search.toolbar.ToolbarView
74 import org.mozilla.fenix.settings.SupportUtils
75 import org.mozilla.fenix.widget.VoiceSearchActivity
77 typealias SearchDialogFragmentStore = SearchFragmentStore
79 @SuppressWarnings("LargeClass", "TooManyFunctions")
80 class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
81     private var _binding: FragmentSearchDialogBinding? = null
82     private val binding get() = _binding!!
84     private var voiceSearchButtonAlreadyAdded: Boolean = false
85     private lateinit var interactor: SearchDialogInteractor
86     private lateinit var store: SearchDialogFragmentStore
87     private lateinit var toolbarView: ToolbarView
88     private lateinit var inlineAutocompleteEditText: InlineAutocompleteEditText
89     private lateinit var awesomeBarView: AwesomeBarView
91     private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
92     private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
93     private var dialogHandledAction = false
95     override fun onStart() {
96         super.onStart()
98         // This will need to be handled for the update to R. We need to resize here in order to
99         // see the whole homescreen behind the search dialog.
100         @Suppress("DEPRECATION")
101         requireActivity().window.setSoftInputMode(
102             WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
103         )
105         // Refocus the toolbar editing and show keyboard if the QR fragment isn't showing
106         if (childFragmentManager.findFragmentByTag(QR_FRAGMENT_TAG) == null) {
107             toolbarView.view.edit.focus()
108         }
109     }
111     override fun onStop() {
112         super.onStop()
113         // https://github.com/mozilla-mobile/fenix/issues/14279
114         // Let's reset back to the default behavior after we're done searching
115         // This will be addressed on https://github.com/mozilla-mobile/fenix/issues/17805
116         @Suppress("DEPRECATION")
117         requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
118     }
120     override fun onCreate(savedInstanceState: Bundle?) {
121         super.onCreate(savedInstanceState)
122         setStyle(STYLE_NO_TITLE, R.style.SearchDialogStyle)
123     }
125     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
126         return object : Dialog(requireContext(), this.theme) {
127             override fun onBackPressed() {
128                 this@SearchDialogFragment.onBackPressed()
129             }
130         }
131     }
133     @SuppressWarnings("LongMethod")
134     override fun onCreateView(
135         inflater: LayoutInflater,
136         container: ViewGroup?,
137         savedInstanceState: Bundle?
138     ): View {
139         val args by navArgs<SearchDialogFragmentArgs>()
140         _binding = FragmentSearchDialogBinding.inflate(inflater, container, false)
141         val activity = requireActivity() as HomeActivity
142         val isPrivate = activity.browsingModeManager.mode.isPrivate
144         requireComponents.analytics.metrics.track(Event.InteractWithSearchURLArea)
146         store = SearchDialogFragmentStore(
147             createInitialSearchFragmentState(
148                 activity,
149                 requireComponents,
150                 tabId = args.sessionId,
151                 pastedText = args.pastedText,
152                 searchAccessPoint = args.searchAccessPoint
153             )
154         )
156         interactor = SearchDialogInteractor(
157             SearchDialogController(
158                 activity = activity,
159                 store = requireComponents.core.store,
160                 tabsUseCases = requireComponents.useCases.tabsUseCases,
161                 fragmentStore = store,
162                 navController = findNavController(),
163                 settings = requireContext().settings(),
164                 metrics = requireComponents.analytics.metrics,
165                 dismissDialog = {
166                     dialogHandledAction = true
167                     dismissAllowingStateLoss()
168                 },
169                 clearToolbarFocus = {
170                     dialogHandledAction = true
171                     toolbarView.view.hideKeyboard()
172                     toolbarView.view.clearFocus()
173                 },
174                 focusToolbar = { toolbarView.view.edit.focus() },
175                 clearToolbar = {
176                     inlineAutocompleteEditText.setText("")
177                 }
178             )
179         )
181         val fromHomeFragment =
182             findNavController().previousBackStackEntry?.destination?.id == R.id.homeFragment
184         toolbarView = ToolbarView(
185             requireContext(),
186             requireContext().settings(),
187             interactor,
188             historyStorageProvider(),
189             isPrivate,
190             binding.toolbar,
191             requireComponents.core.engine,
192             fromHomeFragment
193         ).also {
194             inlineAutocompleteEditText = it.view.findViewById(R.id.mozac_browser_toolbar_edit_url_view)
195         }
197         val awesomeBar = binding.awesomeBar
199         awesomeBarView = AwesomeBarView(
200             activity,
201             interactor,
202             awesomeBar,
203             fromHomeFragment
204         )
206         binding.awesomeBar.setOnTouchListener { _, _ ->
207             binding.root.hideKeyboard()
208             false
209         }
211         awesomeBarView.view.setOnEditSuggestionListener(toolbarView.view::setSearchTerms)
213         inlineAutocompleteEditText.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
215         requireComponents.core.engine.speculativeCreateSession(isPrivate)
217         if (fromHomeFragment) {
218             // When displayed above home, dispatches the touch events to scrim area to the HomeFragment
219             binding.searchWrapper.background = ColorDrawable(Color.TRANSPARENT)
220             dialog?.window?.decorView?.setOnTouchListener { _, event ->
221                 requireActivity().dispatchTouchEvent(event)
222                 // toolbarView.view.displayMode()
223                 false
224             }
225         }
227         return binding.root
228     }
230     @SuppressWarnings("LongMethod")
231     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
232         super.onViewCreated(view, savedInstanceState)
234         consumeFlow(requireComponents.core.store) { flow ->
235             flow.map { state -> state.search }
236                 .ifChanged()
237                 .collect { search ->
238                     store.dispatch(SearchFragmentAction.UpdateSearchState(search))
239                 }
240         }
242         setupConstraints(view)
244         // When displayed above browser or home screen, dismisses keyboard when touching scrim area
245         when (findNavController().previousBackStackEntry?.destination?.id) {
246             R.id.browserFragment, R.id.homeFragment -> {
247                 binding.searchWrapper.setOnTouchListener { _, _ ->
248                     binding.searchWrapper.hideKeyboard()
249                     false
250                 }
251             }
252             else -> {}
253         }
255         binding.searchEnginesShortcutButton.setOnClickListener {
256             interactor.onSearchShortcutsButtonClicked()
257         }
259         qrFeature.set(
260             createQrFeature(),
261             owner = this,
262             view = view
263         )
265         binding.qrScanButton.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
267         binding.qrScanButton.setOnClickListener {
268             if (!requireContext().hasCamera()) { return@setOnClickListener }
269             view.hideKeyboard()
270             toolbarView.view.clearFocus()
272             if (requireContext().settings().shouldShowCameraPermissionPrompt) {
273                 qrFeature.get()?.scan(binding.searchWrapper.id)
274             } else {
275                 if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
276                     qrFeature.get()?.scan(binding.searchWrapper.id)
277                 } else {
278                     interactor.onCameraPermissionsNeeded()
279                     resetFocus()
280                     view.hideKeyboard()
281                     toolbarView.view.requestFocus()
282                 }
283             }
284             requireContext().settings().setCameraPermissionNeededState = false
285         }
287         binding.fillLinkFromClipboard.setOnClickListener {
288             requireComponents.analytics.metrics.track(Event.ClipboardSuggestionClicked)
289             val clipboardUrl = requireContext().components.clipboardHandler.extractURL() ?: ""
291             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
292                 toolbarView.view.edit.updateUrl(clipboardUrl)
293                 hideClipboardSection()
294                 inlineAutocompleteEditText.setSelection(clipboardUrl.length)
295             } else {
296                 view.hideKeyboard()
297                 toolbarView.view.clearFocus()
298                 (activity as HomeActivity)
299                     .openToBrowserAndLoad(
300                         searchTermOrURL = clipboardUrl,
301                         newTab = store.state.tabId == null,
302                         from = BrowserDirection.FromSearchDialog
303                     )
304             }
305         }
307         val stubListener = ViewStub.OnInflateListener { _, inflated ->
308             val searchSuggestionHintBinding = SearchSuggestionsHintBinding.bind(inflated)
310             searchSuggestionHintBinding.learnMore.setOnClickListener {
311                 (activity as HomeActivity)
312                     .openToBrowserAndLoad(
313                         searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(
314                             SupportUtils.SumoTopic.SEARCH_SUGGESTION
315                         ),
316                         newTab = store.state.tabId == null,
317                         from = BrowserDirection.FromSearchDialog
318                     )
319             }
321             searchSuggestionHintBinding.allow.setOnClickListener {
322                 inflated.visibility = View.GONE
323                 requireContext().settings().also {
324                     it.shouldShowSearchSuggestionsInPrivate = true
325                     it.showSearchSuggestionsInPrivateOnboardingFinished = true
326                 }
327                 store.dispatch(SearchFragmentAction.SetShowSearchSuggestions(true))
328                 store.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(false))
329             }
331             searchSuggestionHintBinding.dismiss.setOnClickListener {
332                 inflated.visibility = View.GONE
333                 requireContext().settings().also {
334                     it.shouldShowSearchSuggestionsInPrivate = false
335                     it.showSearchSuggestionsInPrivateOnboardingFinished = true
336                 }
337             }
339             searchSuggestionHintBinding.text.text =
340                 getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name))
342             searchSuggestionHintBinding.title.text =
343                 getString(R.string.search_suggestions_onboarding_title)
344         }
346         binding.searchSuggestionsHint.setOnInflateListener((stubListener))
347         if (view.context.settings().accessibilityServicesEnabled) {
348             updateAccessibilityTraversalOrder()
349         }
351         observeClipboardState()
352         observeAwesomeBarState()
353         observeShortcutsState()
354         observeSuggestionProvidersState()
356         consumeFrom(store) {
357             updateSearchSuggestionsHintVisibility(it)
358             updateToolbarContentDescription(it.searchEngineSource)
359             toolbarView.update(it)
360             awesomeBarView.update(it)
361             addVoiceSearchButton(it)
362         }
363     }
365     private fun hideClipboardSection() {
366         binding.fillLinkFromClipboard.isVisible = false
367         binding.fillLinkDivider.isVisible = false
368         binding.pillWrapperDivider.isVisible = false
369         binding.clipboardUrl.isVisible = false
370         binding.clipboardTitle.isVisible = false
371         binding.linkIcon.isVisible = false
372     }
374     private fun observeSuggestionProvidersState() = consumeFlow(store) { flow ->
375         flow.map { state -> state.toSearchProviderState() }
376             .ifChanged()
377             .collect { state -> awesomeBarView.updateSuggestionProvidersVisibility(state) }
378     }
380     private fun observeShortcutsState() = consumeFlow(store) { flow ->
381         flow.ifAnyChanged { state -> arrayOf(state.areShortcutsAvailable, state.showSearchShortcuts) }
382             .collect { state -> updateSearchShortcutsIcon(state.areShortcutsAvailable, state.showSearchShortcuts) }
383     }
385     private fun observeAwesomeBarState() = consumeFlow(store) { flow ->
386         /*
387          * firstUpdate is used to make sure we keep the awesomebar hidden on the first run
388          *  of the searchFragmentDialog. We only turn it false after the user has changed the
389          *  query as consumeFrom may run several times on fragment start due to state updates.
390          * */
392         flow.map { state -> state.url != state.query && state.query.isNotBlank() || state.showSearchShortcuts }
393             .ifChanged()
394             .collect { shouldShowAwesomebar ->
395                 binding.awesomeBar.visibility = if (shouldShowAwesomebar) {
396                     View.VISIBLE
397                 } else {
398                     View.INVISIBLE
399                 }
400             }
401     }
403     private fun observeClipboardState() = consumeFlow(store) { flow ->
404         flow.map { state ->
405             val shouldShowView = state.showClipboardSuggestions &&
406                 state.query.isEmpty() &&
407                 state.clipboardHasUrl && !state.showSearchShortcuts
408             Pair(shouldShowView, state.clipboardHasUrl)
409         }
410             .ifChanged()
411             .collect { (shouldShowView) ->
412                 updateClipboardSuggestion(shouldShowView)
413             }
414     }
416     private fun updateAccessibilityTraversalOrder() {
417         val searchWrapperId = binding.searchWrapper.id
418         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
419             binding.qrScanButton.accessibilityTraversalAfter = searchWrapperId
420             binding.searchEnginesShortcutButton.accessibilityTraversalAfter = searchWrapperId
421             binding.fillLinkFromClipboard.accessibilityTraversalAfter = searchWrapperId
422         } else {
423             viewLifecycleOwner.lifecycleScope.launch {
424                 binding.searchWrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
425             }
426         }
427     }
429     override fun onResume() {
430         super.onResume()
432         view?.post {
433             // We delay querying the clipboard by posting this code to the main thread message queue,
434             // because ClipboardManager will return null if the does app not have input focus yet.
435             lifecycleScope.launch(Dispatchers.Cached) {
436                 val hasUrl = context?.components?.clipboardHandler?.containsURL() ?: false
437                 store.dispatch(SearchFragmentAction.UpdateClipboardHasUrl(hasUrl))
438             }
439         }
440     }
442     override fun onPause() {
443         super.onPause()
444         view?.hideKeyboard()
445     }
447     override fun onDestroyView() {
448         super.onDestroyView()
450         _binding = null
451     }
453     /*
454      * This way of dismissing the keyboard is needed to smoothly dismiss the keyboard while the dialog
455      * is also dismissing. For example, when clicking a top site on home while this dialog is showing.
456      */
457     private fun hideDeviceKeyboard() {
458         // If the interactor/controller has handled a search event itself, it will hide the keyboard.
459         if (!dialogHandledAction) {
460             val imm =
461                 requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
462             imm.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY)
463         }
464     }
466     override fun onDismiss(dialog: DialogInterface) {
467         super.onDismiss(dialog)
468         hideDeviceKeyboard()
469     }
471     override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
472         if (requestCode == VoiceSearchActivity.SPEECH_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
473             intent?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.first()?.also {
474                 toolbarView.view.edit.updateUrl(url = it, shouldHighlight = true)
475                 interactor.onTextChanged(it)
476                 toolbarView.view.edit.focus()
477             }
478         }
479     }
481     override fun onBackPressed(): Boolean {
482         return when {
483             qrFeature.onBackPressed() -> {
484                 resetFocus()
485                 true
486             }
487             else -> {
488                 // In case we're displaying search results, we wouldn't have navigated to home, and
489                 // so we don't need to navigate "back to" browser fragment.
490                 // See mirror of this logic in BrowserToolbarController#handleToolbarClick.
491                 if (store.state.searchTerms.isBlank()) {
492                     val args by navArgs<SearchDialogFragmentArgs>()
493                     args.sessionId?.let {
494                         findNavController().navigate(
495                             SearchDialogFragmentDirections.actionGlobalBrowser(null)
496                         )
497                     }
498                 }
499                 view?.hideKeyboard()
500                 dismissAllowingStateLoss()
501                 true
502             }
503         }
504     }
506     private fun historyStorageProvider(): HistoryStorage? {
507         return if (requireContext().settings().shouldShowHistorySuggestions) {
508             requireComponents.core.historyStorage
509         } else null
510     }
512     @Suppress("DEPRECATION")
513     // https://github.com/mozilla-mobile/fenix/issues/19920
514     private fun createQrFeature(): QrFeature {
515         return QrFeature(
516             requireContext(),
517             fragmentManager = childFragmentManager,
518             onNeedToRequestPermissions = { permissions ->
519                 requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
520             },
521             onScanResult = { result ->
522                 val normalizedUrl = result.toNormalizedUrl()
523                 if (!normalizedUrl.toUri().isHttpOrHttps) {
524                     activity?.let {
525                         AlertDialog.Builder(it).apply {
526                             setMessage(R.string.qr_scanner_dialog_invalid)
527                             setPositiveButton(R.string.qr_scanner_dialog_invalid_ok) { dialog: DialogInterface, _ ->
528                                 dialog.dismiss()
529                             }
530                             create()
531                         }.show()
532                     }
533                 } else {
534                     binding.qrScanButton.isChecked = false
535                     activity?.let {
536                         AlertDialog.Builder(it).apply {
537                             val spannable = resources.getSpanned(
538                                 R.string.qr_scanner_confirmation_dialog_message,
539                                 getString(R.string.app_name) to StyleSpan(Typeface.BOLD),
540                                 normalizedUrl to StyleSpan(Typeface.ITALIC)
541                             )
542                             setMessage(spannable)
543                             setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
544                                 dialog.cancel()
545                             }
546                             setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
547                                 (activity as? HomeActivity)?.openToBrowserAndLoad(
548                                     searchTermOrURL = normalizedUrl,
549                                     newTab = store.state.tabId == null,
550                                     from = BrowserDirection.FromSearchDialog,
551                                     flags = EngineSession.LoadUrlFlags.external()
552                                 )
553                                 dialog.dismiss()
554                             }
555                             create()
556                         }.show()
557                     }
558                 }
559             }
560         )
561     }
563     @Suppress("DEPRECATION")
564     // https://github.com/mozilla-mobile/fenix/issues/19920
565     override fun onRequestPermissionsResult(
566         requestCode: Int,
567         permissions: Array<String>,
568         grantResults: IntArray
569     ) {
570         when (requestCode) {
571             REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
572                 it.onPermissionsResult(permissions, grantResults)
573                 resetFocus()
574                 requireContext().settings().setCameraPermissionNeededState = false
575             }
576             else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
577         }
578     }
580     private fun resetFocus() {
581         binding.qrScanButton.isChecked = false
582         toolbarView.view.edit.focus()
583         toolbarView.view.requestFocus()
584     }
586     private fun setupConstraints(view: View) {
587         if (view.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) {
588             ConstraintSet().apply {
589                 clone(binding.searchWrapper)
591                 clear(binding.toolbar.id, TOP)
592                 connect(binding.toolbar.id, BOTTOM, PARENT_ID, BOTTOM)
594                 clear(binding.pillWrapper.id, BOTTOM)
595                 connect(binding.pillWrapper.id, BOTTOM, binding.toolbar.id, TOP)
597                 clear(binding.awesomeBar.id, TOP)
598                 clear(binding.awesomeBar.id, BOTTOM)
599                 connect(binding.awesomeBar.id, TOP, binding.searchSuggestionsHint.id, BOTTOM)
600                 connect(binding.awesomeBar.id, BOTTOM, binding.pillWrapper.id, TOP)
602                 clear(binding.searchSuggestionsHint.id, TOP)
603                 clear(binding.searchSuggestionsHint.id, BOTTOM)
604                 connect(binding.searchSuggestionsHint.id, TOP, PARENT_ID, TOP)
605                 connect(binding.searchSuggestionsHint.id, BOTTOM, binding.searchHintBottomBarrier.id, TOP)
607                 clear(binding.fillLinkFromClipboard.id, TOP)
608                 connect(binding.fillLinkFromClipboard.id, BOTTOM, binding.pillWrapper.id, TOP)
610                 clear(binding.fillLinkDivider.id, TOP)
611                 connect(binding.fillLinkDivider.id, BOTTOM, binding.fillLinkFromClipboard.id, TOP)
613                 applyTo(binding.searchWrapper)
614             }
615         }
616     }
618     private fun updateSearchSuggestionsHintVisibility(state: SearchFragmentState) {
619         view?.apply {
620             val showHint = state.showSearchSuggestionsHint &&
621                 !state.showSearchShortcuts &&
622                 state.url != state.query
624             binding.searchSuggestionsHint.isVisible = showHint
625             binding.searchSuggestionsHintDivider.isVisible = showHint
626         }
627     }
629     private fun addVoiceSearchButton(searchFragmentState: SearchFragmentState) {
630         if (voiceSearchButtonAlreadyAdded) return
631         val searchEngine = searchFragmentState.searchEngineSource.searchEngine
633         val isVisible =
634             searchEngine?.id?.contains("google") == true &&
635                 isSpeechAvailable() &&
636                 requireContext().settings().shouldShowVoiceSearch
638         if (isVisible) {
639             toolbarView.view.addEditAction(
640                 BrowserToolbar.Button(
641                     AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!,
642                     requireContext().getString(R.string.voice_search_content_description),
643                     visible = { true },
644                     listener = ::launchVoiceSearch
645                 )
646             )
647             voiceSearchButtonAlreadyAdded = true
648         }
649     }
651     @Suppress("DEPRECATION")
652     // https://github.com/mozilla-mobile/fenix/issues/19919
653     private fun launchVoiceSearch() {
654         // Note if a user disables speech while the app is on the search fragment
655         // the voice button will still be available and *will* cause a crash if tapped,
656         // since the `visible` call is only checked on create. In order to avoid extra complexity
657         // around such a small edge case, we make the button have no functionality in this case.
658         if (!isSpeechAvailable()) { return }
660         requireComponents.analytics.metrics.track(Event.VoiceSearchTapped)
661         speechIntent.apply {
662             putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
663             putExtra(RecognizerIntent.EXTRA_PROMPT, requireContext().getString(R.string.voice_search_explainer))
664         }
665         startActivityForResult(speechIntent, VoiceSearchActivity.SPEECH_REQUEST_CODE)
666     }
668     private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null
670     private fun updateClipboardSuggestion(
671         shouldShowView: Boolean
672     ) {
673         binding.fillLinkFromClipboard.isVisible = shouldShowView
674         binding.fillLinkDivider.isVisible = shouldShowView
675         binding.pillWrapperDivider.isVisible =
676             !(shouldShowView && requireComponents.settings.shouldUseBottomToolbar)
677         binding.clipboardTitle.isVisible = shouldShowView
678         binding.linkIcon.isVisible = shouldShowView
680         if (shouldShowView) {
681             val contentDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
682                 "${binding.clipboardTitle.text}."
683             } else {
684                 val clipboardUrl = context?.components?.clipboardHandler?.extractURL()
686                 if (clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) {
687                     requireComponents.core.engine.speculativeConnect(clipboardUrl)
688                 }
689                 binding.clipboardUrl.text = clipboardUrl
690                 binding.clipboardUrl.isVisible = shouldShowView
691                 "${binding.clipboardTitle.text}, ${binding.clipboardUrl.text}."
692             }
694             binding.fillLinkFromClipboard.contentDescription = contentDescription
695         }
696     }
698     private fun updateToolbarContentDescription(source: SearchEngineSource) {
699         source.searchEngine?.let { engine ->
700             toolbarView.view.contentDescription = engine.name + ", " + inlineAutocompleteEditText.hint
701         }
703         inlineAutocompleteEditText.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
704     }
706     private fun updateSearchShortcutsIcon(
707         areShortcutsAvailable: Boolean,
708         showShortcuts: Boolean
709     ) {
710         view?.apply {
711             binding.searchEnginesShortcutButton.isVisible = areShortcutsAvailable
712             binding.searchEnginesShortcutButton.isChecked = showShortcuts
714             val color = if (showShortcuts) R.attr.contrastText else R.attr.textPrimary
715             binding.searchEnginesShortcutButton.compoundDrawables[0]?.setTint(
716                 requireContext().getColorFromAttr(color)
717             )
718         }
719     }
721     companion object {
722         private const val QR_FRAGMENT_TAG = "MOZAC_QR_FRAGMENT"
723         private const val REQUEST_CODE_CAMERA_PERMISSIONS = 1
724     }