You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkSearchDialogFragmen...

316 lines
12 KiB
Kotlin

/* 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.library.bookmarks
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.InputMethodManager
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintProperties.BOTTOM
import androidx.constraintlayout.widget.ConstraintProperties.PARENT_ID
import androidx.constraintlayout.widget.ConstraintProperties.TOP
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.databinding.FragmentBookmarkSearchDialogBinding
import org.mozilla.fenix.databinding.SearchSuggestionsHintBinding
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.library.bookmarks.awesomebar.AwesomeBarView
import org.mozilla.fenix.library.bookmarks.toolbar.ToolbarView
import org.mozilla.fenix.settings.SupportUtils
@Suppress("TooManyFunctions", "LargeClass")
class BookmarkSearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private var _binding: FragmentBookmarkSearchDialogBinding? = null
private val binding get() = _binding!!
private lateinit var interactor: BookmarkSearchDialogInteractor
private lateinit var store: BookmarkSearchFragmentStore
private lateinit var toolbarView: ToolbarView
private lateinit var awesomeBarView: AwesomeBarView
private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
private var voiceSearchButtonAlreadyAdded = false
private var dialogHandledAction = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.SearchDialogStyle)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireContext(), this.theme) {
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
this@BookmarkSearchDialogFragment.onBackPressed()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentBookmarkSearchDialogBinding.inflate(inflater, container, false)
val activity = requireActivity() as HomeActivity
store = BookmarkSearchFragmentStore(
createInitialBookmarkSearchFragmentState(),
)
interactor = BookmarkSearchDialogInteractor(
BookmarkSearchDialogController(
activity = activity,
fragmentStore = store,
clearToolbarFocus = {
dialogHandledAction = true
toolbarView.view.hideKeyboard()
toolbarView.view.clearFocus()
},
),
)
toolbarView = ToolbarView(
context = requireContext(),
interactor = interactor,
isPrivate = false,
view = binding.toolbar,
)
val awesomeBar = binding.awesomeBar
awesomeBarView = AwesomeBarView(
activity,
interactor,
awesomeBar,
)
awesomeBarView.view.setOnEditSuggestionListener(toolbarView.view::setSearchTerms)
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupConstraints(view)
binding.searchWrapper.setOnTouchListener { _, _ ->
dismissAllowingStateLoss()
true
}
val stubListener = ViewStub.OnInflateListener { _, inflated ->
val searchSuggestionHintBinding = SearchSuggestionsHintBinding.bind(inflated)
searchSuggestionHintBinding.learnMore.setOnClickListener {
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(
SupportUtils.SumoTopic.SEARCH_SUGGESTION,
),
newTab = true,
from = BrowserDirection.FromBookmarkSearchDialog,
)
}
searchSuggestionHintBinding.allow.setOnClickListener {
inflated.visibility = View.GONE
requireContext().settings().also {
it.shouldShowSearchSuggestionsInPrivate = true
it.showSearchSuggestionsInPrivateOnboardingFinished = true
}
}
searchSuggestionHintBinding.dismiss.setOnClickListener {
inflated.visibility = View.GONE
requireContext().settings().also {
it.shouldShowSearchSuggestionsInPrivate = false
it.showSearchSuggestionsInPrivateOnboardingFinished = true
}
}
searchSuggestionHintBinding.text.text =
getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name))
searchSuggestionHintBinding.title.text =
getString(R.string.search_suggestions_onboarding_title)
}
binding.searchSuggestionsHintDivider.isVisible = false
binding.searchSuggestionsHint.isVisible = false
binding.searchSuggestionsHint.setOnInflateListener((stubListener))
if (view.context.settings().accessibilityServicesEnabled) {
updateAccessibilityTraversalOrder()
}
addVoiceSearchButton()
observeAwesomeBarState()
consumeFrom(store) {
toolbarView.update(it)
awesomeBarView.update(it)
}
}
private fun observeAwesomeBarState() = consumeFlow(store) { flow ->
flow.map { state -> state.query.isNotBlank() }
.ifChanged()
.collect { shouldShowAwesomebar ->
binding.awesomeBar.visibility = if (shouldShowAwesomebar) {
View.VISIBLE
} else {
View.INVISIBLE
}
}
}
private fun updateAccessibilityTraversalOrder() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
viewLifecycleOwner.lifecycleScope.launch {
binding.searchWrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
}
}
override fun onPause() {
super.onPause()
view?.hideKeyboard()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/*
* This way of dismissing the keyboard is needed to smoothly dismiss the keyboard while the dialog
* is also dismissing.
*/
private fun hideDeviceKeyboard() {
// If the interactor/controller has handled a search event itself, it will hide the keyboard.
if (!dialogHandledAction) {
val imm =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
hideDeviceKeyboard()
}
override fun onBackPressed(): Boolean {
view?.hideKeyboard()
dismissAllowingStateLoss()
return true
}
private fun setupConstraints(view: View) {
if (view.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) {
ConstraintSet().apply {
clone(binding.searchWrapper)
clear(binding.toolbar.id, TOP)
connect(binding.toolbar.id, BOTTOM, PARENT_ID, BOTTOM)
clear(binding.pillWrapper.id, BOTTOM)
connect(binding.pillWrapper.id, BOTTOM, binding.toolbar.id, TOP)
clear(binding.awesomeBar.id, TOP)
clear(binding.awesomeBar.id, BOTTOM)
connect(binding.awesomeBar.id, TOP, binding.searchSuggestionsHint.id, BOTTOM)
connect(binding.awesomeBar.id, BOTTOM, binding.pillWrapper.id, TOP)
clear(binding.searchSuggestionsHint.id, TOP)
clear(binding.searchSuggestionsHint.id, BOTTOM)
connect(binding.searchSuggestionsHint.id, TOP, PARENT_ID, TOP)
connect(binding.searchSuggestionsHint.id, BOTTOM, binding.searchHintBottomBarrier.id, TOP)
applyTo(binding.searchWrapper)
}
}
}
private val startVoiceSearchForResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
intent?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.first()?.also {
toolbarView.view.edit.updateUrl(url = it, shouldHighlight = true)
interactor.onTextChanged(it)
toolbarView.view.edit.focus()
}
}
}
private fun addVoiceSearchButton() {
val shouldShowVoiceSearch = isSpeechAvailable() &&
requireContext().settings().shouldShowVoiceSearch
if (voiceSearchButtonAlreadyAdded || !shouldShowVoiceSearch) return
toolbarView.view.addEditActionEnd(
BrowserToolbar.Button(
imageDrawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!,
contentDescription = requireContext().getString(R.string.voice_search_content_description),
visible = { true },
listener = ::launchVoiceSearch,
),
)
voiceSearchButtonAlreadyAdded = true
}
private fun launchVoiceSearch() {
// Note if a user disables speech while the app is on the search fragment
// the voice button will still be available and *will* cause a crash if tapped,
// since the `visible` call is only checked on create. In order to avoid extra complexity
// around such a small edge case, we make the button have no functionality in this case.
if (!isSpeechAvailable()) { return }
speechIntent.apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PROMPT, requireContext().getString(R.string.voice_search_explainer))
}
startVoiceSearchForResult.launch(speechIntent)
}
private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null
}