/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.fenix.search import android.Manifest import android.app.Activity.RESULT_OK import android.content.Context import android.content.DialogInterface import android.content.Intent import android.graphics.Typeface.BOLD import android.graphics.Typeface.ITALIC import android.os.Bundle import android.speech.RecognizerIntent import android.speech.RecognizerIntent.EXTRA_RESULTS import android.text.style.StyleSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewStub import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import kotlinx.android.synthetic.main.fragment_search.* import kotlinx.android.synthetic.main.fragment_search.view.* import kotlinx.android.synthetic.main.search_suggestions_onboarding.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.storage.HistoryStorage import mozilla.components.feature.qr.QrFeature import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.ktx.android.content.hasCamera import mozilla.components.support.ktx.android.content.isPermissionGranted import mozilla.components.ui.autocomplete.InlineAutocompleteEditText import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getSpannable import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.search.awesomebar.AwesomeBarView import org.mozilla.fenix.search.toolbar.ToolbarView import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_REQUEST_CODE @Suppress("TooManyFunctions", "LargeClass") class SearchFragment : Fragment(), UserInteractionHandler { private lateinit var toolbarView: ToolbarView private lateinit var awesomeBarView: AwesomeBarView private val qrFeature = ViewBoundFeatureWrapper() private var permissionDidUpdate = false private lateinit var searchStore: SearchFragmentStore private lateinit var searchInteractor: SearchInteractor private fun shouldShowSearchSuggestions(isPrivate: Boolean): Boolean = if (isPrivate) { requireContext().settings().shouldShowSearchSuggestions && requireContext().settings().shouldShowSearchSuggestionsInPrivate } else { requireContext().settings().shouldShowSearchSuggestions } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val args = arguments?.let { navArgs().value } val session = args?.sessionId ?.let(requireComponents.core.sessionManager::findSessionById) val pastedText = args?.pastedText val searchAccessPoint = args?.searchAccessPoint val view = inflater.inflate(R.layout.fragment_search, container, false) val url = session?.url.orEmpty() val currentSearchEngine = SearchEngineSource.Default( requireComponents.search.provider.getDefaultEngine(requireContext()) ) val isPrivate = (activity as HomeActivity).browsingModeManager.mode.isPrivate requireComponents.analytics.metrics.track(Event.InteractWithSearchURLArea) searchStore = StoreProvider.get(this) { SearchFragmentStore( SearchFragmentState( query = url, searchEngineSource = currentSearchEngine, defaultEngineSource = currentSearchEngine, showSearchSuggestions = shouldShowSearchSuggestions(isPrivate), showSearchSuggestionsHint = false, showSearchShortcuts = requireContext().settings().shouldShowSearchShortcuts && url.isEmpty(), showClipboardSuggestions = requireContext().settings().shouldShowClipboardSuggestions, showHistorySuggestions = requireContext().settings().shouldShowHistorySuggestions, showBookmarkSuggestions = requireContext().settings().shouldShowBookmarkSuggestions, session = session, pastedText = pastedText, searchAccessPoint = searchAccessPoint ) ) } val searchController = DefaultSearchController( context = activity as HomeActivity, store = searchStore, navController = findNavController(), viewLifecycleScope = viewLifecycleOwner.lifecycleScope, clearToolbarFocus = ::clearToolbarFocus ) searchInteractor = SearchInteractor( searchController ) awesomeBarView = AwesomeBarView(view.scrollable_area, searchInteractor) toolbarView = ToolbarView( view.toolbar_component_wrapper, searchInteractor, historyStorageProvider(), isPrivate, requireComponents.core.engine ) toolbarView.view.addEditAction( BrowserToolbar.Button( ContextCompat.getDrawable(requireContext(), R.drawable.ic_microphone)!!, requireContext().getString(R.string.voice_search_content_description), visible = { requireContext().settings().shouldShowVoiceSearch && FeatureFlags.voiceSearch }, listener = ::launchVoiceSearch ) ) val urlView = toolbarView.view .findViewById(R.id.mozac_browser_toolbar_edit_url_view) urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO requireComponents.core.engine.speculativeCreateSession(isPrivate) startPostponedEnterTransition() return view } private fun launchVoiceSearch() { requireComponents.analytics.metrics.track(Event.VoiceSearchTapped) val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_PROMPT, requireContext().getString(R.string.voice_search_explainer)) } startActivityForResult(speechIntent, SPEECH_REQUEST_CODE) } private fun clearToolbarFocus() { toolbarView.view.clearFocus() } @ExperimentalCoroutinesApi @SuppressWarnings("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) search_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE qrFeature.set( QrFeature( requireContext(), fragmentManager = parentFragmentManager, onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS) }, onScanResult = { result -> search_scan_button.isChecked = false activity?.let { AlertDialog.Builder(it).apply { val spannable = resources.getSpannable( R.string.qr_scanner_confirmation_dialog_message, listOf( getString(R.string.app_name) to listOf(StyleSpan(BOLD)), result to listOf(StyleSpan(ITALIC)) ) ) setMessage(spannable) setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ -> requireComponents.analytics.metrics.track(Event.QRScannerNavigationDenied) dialog.cancel() } setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ -> requireComponents.analytics.metrics.track(Event.QRScannerNavigationAllowed) (activity as HomeActivity) .openToBrowserAndLoad( searchTermOrURL = result, newTab = searchStore.state.session == null, from = BrowserDirection.FromSearch ) dialog.dismiss() } create() }.show() requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed) } }), owner = this, view = view ) view.search_scan_button.setOnClickListener { toolbarView.view.clearFocus() requireComponents.analytics.metrics.track(Event.QRScannerOpened) qrFeature.get()?.scan(R.id.container) } view.search_shortcuts_button.setOnClickListener { searchInteractor.onSearchShortcutsButtonClicked() } val stubListener = ViewStub.OnInflateListener { _, inflated -> inflated.learn_more.setOnClickListener { (activity as HomeActivity) .openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic( SupportUtils.SumoTopic.SEARCH_SUGGESTION ), newTab = searchStore.state.session == null, from = BrowserDirection.FromSearch ) } inflated.allow.setOnClickListener { inflated.visibility = View.GONE context?.settings()?.shouldShowSearchSuggestionsInPrivate = true context?.settings()?.showSearchSuggestionsInPrivateOnboardingFinished = true searchStore.dispatch(SearchFragmentAction.SetShowSearchSuggestions(true)) searchStore.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(false)) requireComponents.analytics.metrics.track(Event.PrivateBrowsingShowSearchSuggestions) } inflated.dismiss.setOnClickListener { inflated.visibility = View.GONE context?.settings()?.shouldShowSearchSuggestionsInPrivate = false context?.settings()?.showSearchSuggestionsInPrivateOnboardingFinished = true } inflated.text.text = getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name)) inflated.title.text = getString(R.string.search_suggestions_onboarding_title) } view.search_suggestions_onboarding.setOnInflateListener((stubListener)) view.toolbar_wrapper.clipToOutline = false fill_link_from_clipboard.setOnClickListener { (activity as HomeActivity) .openToBrowserAndLoad( searchTermOrURL = requireContext().components.clipboardHandler.url ?: "", newTab = searchStore.state.session == null, from = BrowserDirection.FromSearch ) } consumeFrom(searchStore) { awesomeBarView.update(it) updateSearchShortcutsIcon(it) toolbarView.update(it) updateSearchWithLabel(it) updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url) updateSearchSuggestionsHintVisibility(it) } startPostponedEnterTransition() } override fun onResume() { super.onResume() // The user has the option to go to 'Shortcuts' -> 'Search engine settings' to modify the default search engine. // When returning from that settings screen we need to update it to account for any changes. val currentDefaultEngine = requireComponents.search.provider.getDefaultEngine(requireContext()) if (searchStore.state.defaultEngineSource.searchEngine != currentDefaultEngine) { searchStore.dispatch( SearchFragmentAction.SelectNewDefaultSearchEngine (currentDefaultEngine) ) } if (!permissionDidUpdate) { toolbarView.view.edit.focus() } updateClipboardSuggestion( searchStore.state, requireContext().components.clipboardHandler.url ) permissionDidUpdate = false hideToolbar() } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { if (requestCode == SPEECH_REQUEST_CODE && resultCode == RESULT_OK) { intent?.getStringArrayListExtra(EXTRA_RESULTS)?.first()?.also { toolbarView.view.edit.updateUrl(url = it, shouldHighlight = true) searchInteractor.onTextChanged(it) toolbarView.view.edit.focus() } } } override fun onPause() { super.onPause() toolbarView.view.clearFocus() } override fun onBackPressed(): Boolean { // Note: Actual navigation happens in `handleEditingCancelled` in SearchController return when { qrFeature.onBackPressed() -> { toolbarView.view.edit.focus() view?.search_scan_button?.isChecked = false toolbarView.view.requestFocus() } else -> awesomeBarView.isKeyboardDismissedProgrammatically } } private fun updateSearchWithLabel(searchState: SearchFragmentState) { search_with_shortcuts.visibility = if (searchState.showSearchShortcuts) View.VISIBLE else View.GONE } private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) { val visibility = if (searchState.showClipboardSuggestions && searchState.query.isEmpty() && !clipboardUrl.isNullOrEmpty()) View.VISIBLE else View.GONE fill_link_from_clipboard.visibility = visibility divider_line.visibility = visibility clipboard_url.text = clipboardUrl if (clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) { requireComponents.core.engine.speculativeConnect(clipboardUrl) } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { when (requestCode) { REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature { it.onPermissionsResult(permissions, grantResults) context?.let { context: Context -> if (context.isPermissionGranted(Manifest.permission.CAMERA)) { permissionDidUpdate = true } else { view?.search_scan_button?.isChecked = false } } } else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) } } private fun historyStorageProvider(): HistoryStorage? { return if (requireContext().settings().shouldShowHistorySuggestions) { requireComponents.core.historyStorage } else null } private fun updateSearchSuggestionsHintVisibility(state: SearchFragmentState) { view?.apply { findViewById(R.id.search_suggestions_onboarding)?.isVisible = state.showSearchSuggestionsHint search_suggestions_onboarding_divider?.isVisible = search_with_shortcuts.isVisible && state.showSearchSuggestionsHint } } private fun updateSearchShortcutsIcon(searchState: SearchFragmentState) { view?.apply { val showShortcuts = searchState.showSearchShortcuts search_shortcuts_button.isChecked = showShortcuts val color = if (showShortcuts) R.attr.contrastText else R.attr.primaryText search_shortcuts_button.compoundDrawables[0]?.setTint( requireContext().getColorFromAttr(color) ) } } companion object { private const val SHARED_TRANSITION_MS = 250L private const val REQUEST_CODE_CAMERA_PERMISSIONS = 1 } }