Close #12717: Add bookmark search

This commit is contained in:
Roger Yang 2022-03-04 13:06:51 -05:00 committed by mergify[bot]
parent 3727d55b88
commit ed71b8a92c
23 changed files with 1092 additions and 0 deletions

View File

@ -19,6 +19,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromSearchDialog(R.id.searchDialogFragment),
FromSettings(R.id.settingsFragment),
FromBookmarks(R.id.bookmarkFragment),
FromBookmarkSearchDialog(R.id.bookmarkSearchDialogFragment),
FromHistory(R.id.historyFragment),
FromHistorySearchDialog(R.id.historySearchDialogFragment),
FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment),

View File

@ -788,6 +788,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
SettingsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromBookmarks ->
BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromBookmarkSearchDialog ->
SearchDialogFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistory ->
HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistorySearchDialog ->

View File

@ -25,6 +25,7 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.bookmarkStorage
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.navigateSafe
/**
* [BookmarkFragment] controller.
@ -47,6 +48,7 @@ interface BookmarkController {
fun handleBookmarkFolderDeletion(nodes: Set<BookmarkNode>)
fun handleRequestSync()
fun handleBackPressed()
fun handleSearch()
}
@Suppress("TooManyFunctions")
@ -184,6 +186,12 @@ class DefaultBookmarkController(
}
}
override fun handleSearch() {
val directions =
BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkSearchDialogFragment()
navController.navigateSafe(R.id.bookmarkFragment, directions)
}
private fun openInNewTabAndShow(
searchTermOrURL: String,
newTab: Boolean,

View File

@ -35,6 +35,7 @@ import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.NavHostActivity
import org.mozilla.fenix.R
@ -178,6 +179,10 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
if (mode.showMenu) {
inflater.inflate(R.menu.bookmarks_menu, menu)
}
if (!FeatureFlags.historyImprovementFeatures) {
menu.findItem(R.id.bookmark_search)?.isVisible = false
}
}
is BookmarkFragmentState.Mode.Selecting -> {
if (mode.selectedItems.any { it.type != BookmarkNodeType.ITEM }) {
@ -196,6 +201,10 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.bookmark_search -> {
bookmarkInteractor.onSearch()
true
}
R.id.close_bookmarks -> {
invokePendingDeletion()
close()

View File

@ -42,6 +42,10 @@ class BookmarkFragmentInteractor(
bookmarksController.handleAllBookmarksDeselected()
}
override fun onSearch() {
bookmarksController.handleSearch()
}
/**
* Copies the URL of the given BookmarkNode into the copy and paste buffer.
*/

View File

@ -0,0 +1,44 @@
/* 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 mozilla.components.concept.engine.EngineSession.LoadUrlFlags
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
/**
* An interface that handles the view manipulation of the Bookmark Search, triggered by the Interactor
*/
interface BookmarkSearchController {
fun handleEditingCancelled()
fun handleTextChanged(text: String)
fun handleUrlTapped(url: String, flags: LoadUrlFlags = LoadUrlFlags.none())
}
class BookmarkSearchDialogController(
private val activity: HomeActivity,
private val fragmentStore: BookmarkSearchFragmentStore,
private val clearToolbarFocus: () -> Unit,
) : BookmarkSearchController {
override fun handleEditingCancelled() {
clearToolbarFocus()
}
override fun handleTextChanged(text: String) {
fragmentStore.dispatch(BookmarkSearchFragmentAction.UpdateQuery(text))
}
override fun handleUrlTapped(url: String, flags: LoadUrlFlags) {
clearToolbarFocus()
activity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarkSearchDialog,
flags = flags
)
}
}

View File

@ -0,0 +1,314 @@
/* 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) {
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.addEditAction(
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
}

View File

@ -0,0 +1,30 @@
/* 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 mozilla.components.concept.engine.EngineSession.LoadUrlFlags
import org.mozilla.fenix.library.bookmarks.awesomebar.AwesomeBarInteractor
import org.mozilla.fenix.library.bookmarks.toolbar.ToolbarInteractor
/**
* Interactor for the bookmark search
* Provides implementations for the AwesomeBarView and ToolbarView
*/
class BookmarkSearchDialogInteractor(
private val bookmarkSearchController: BookmarkSearchDialogController
) : AwesomeBarInteractor, ToolbarInteractor {
override fun onEditingCanceled() {
bookmarkSearchController.handleEditingCancelled()
}
override fun onTextChanged(text: String) {
bookmarkSearchController.handleTextChanged(text)
}
override fun onUrlTapped(url: String, flags: LoadUrlFlags) {
bookmarkSearchController.handleUrlTapped(url, flags)
}
}

View File

@ -0,0 +1,53 @@
/* 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 mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [Store] for holding the [BookmarkSearchFragmentState] and applying [BookmarkSearchFragmentAction]s.
*/
class BookmarkSearchFragmentStore(
initialState: BookmarkSearchFragmentState
) : Store<BookmarkSearchFragmentState, BookmarkSearchFragmentAction>(
initialState,
::bookmarkSearchStateReducer
)
/**
* The state for the Bookmark Search Screen
*
* @property query The current search query string
*/
data class BookmarkSearchFragmentState(
val query: String,
) : State
fun createInitialBookmarkSearchFragmentState(): BookmarkSearchFragmentState {
return BookmarkSearchFragmentState(query = "")
}
/**
* Actions to dispatch through the [BookmarkSearchFragmentStore] to modify [BookmarkSearchFragmentState]
* through the reducer.
*/
sealed class BookmarkSearchFragmentAction : Action {
data class UpdateQuery(val query: String) : BookmarkSearchFragmentAction()
}
/**
* The [BookmarkSearchFragmentState] Reducer.
*/
private fun bookmarkSearchStateReducer(
state: BookmarkSearchFragmentState,
action: BookmarkSearchFragmentAction
): BookmarkSearchFragmentState {
return when (action) {
is BookmarkSearchFragmentAction.UpdateQuery ->
state.copy(query = action.query)
}
}

View File

@ -97,6 +97,11 @@ interface BookmarkViewInteractor : SelectionInteractor<BookmarkNode> {
*
*/
fun onRequestSync()
/**
* Handles when search is tapped
*/
fun onSearch()
}
class BookmarkView(

View File

@ -0,0 +1,20 @@
/* 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.awesomebar
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
/**
* Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want
* to respond to user interaction on the AwesomebarView
*/
interface AwesomeBarInteractor {
/**
* Called whenever a suggestion containing a URL is tapped
* @param url the url the suggestion was providing
*/
fun onUrlTapped(url: String, flags: LoadUrlFlags = LoadUrlFlags.none())
}

View File

@ -0,0 +1,60 @@
/* 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.awesomebar
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import mozilla.components.concept.engine.EngineSession
import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
import mozilla.components.feature.session.SessionUseCases
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.library.bookmarks.BookmarkSearchFragmentState
/**
* View that contains and configures the BrowserAwesomeBar
*/
class AwesomeBarView(
activity: HomeActivity,
val interactor: AwesomeBarInteractor,
val view: AwesomeBarWrapper,
) {
private val bookmarksStorageSuggestionProvider: BookmarksStorageSuggestionProvider
private val loadUrlUseCase = object : SessionUseCases.LoadUrlUseCase {
override fun invoke(
url: String,
flags: EngineSession.LoadUrlFlags,
additionalHeaders: Map<String, String>?
) {
interactor.onUrlTapped(url, flags)
}
}
init {
val components = activity.components
val engineForSpeculativeConnects = when (activity.browsingModeManager.mode) {
BrowsingMode.Normal -> components.core.engine
BrowsingMode.Private -> null
}
bookmarksStorageSuggestionProvider =
BookmarksStorageSuggestionProvider(
bookmarksStorage = components.core.bookmarksStorage,
loadUrlUseCase = loadUrlUseCase,
icons = components.core.icons,
indicatorIcon = getDrawable(activity, R.drawable.ic_search_results_bookmarks),
engine = engineForSpeculativeConnects
)
view.addProviders(bookmarksStorageSuggestionProvider)
}
fun update(state: BookmarkSearchFragmentState) {
view.onInputChanged(state.query)
}
}

View File

@ -0,0 +1,104 @@
/* 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.awesomebar
import android.content.Context
import android.util.AttributeSet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import mozilla.components.compose.browser.awesomebar.AwesomeBar
import mozilla.components.compose.browser.awesomebar.AwesomeBarDefaults
import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation
import mozilla.components.concept.awesomebar.AwesomeBar
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.theme.FirefoxTheme
/**
* This wrapper wraps the `AwesomeBar()` composable and exposes it as a `View` and `concept-awesomebar`
* implementation to be integrated in the view hierarchy of [BookmarkSearchDialogFragment] until more parts
* of that screen have been refactored to use Jetpack Compose.
*/
class AwesomeBarWrapper @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr), AwesomeBar {
private val providers = mutableStateOf(emptyList<AwesomeBar.SuggestionProvider>())
private val text = mutableStateOf("")
private var onEditSuggestionListener: ((String) -> Unit)? = null
private var onStopListener: (() -> Unit)? = null
@Composable
override fun Content() {
if (providers.value.isEmpty()) {
return
}
val orientation = if (context.settings().shouldUseBottomToolbar) {
AwesomeBarOrientation.BOTTOM
} else {
AwesomeBarOrientation.TOP
}
FirefoxTheme {
AwesomeBar(
text = text.value,
providers = providers.value,
orientation = orientation,
colors = AwesomeBarDefaults.colors(
background = Color.Transparent,
title = FirefoxTheme.colors.textPrimary,
description = FirefoxTheme.colors.textSecondary,
autocompleteIcon = FirefoxTheme.colors.textSecondary
),
onSuggestionClicked = { suggestion ->
suggestion.onSuggestionClicked?.invoke()
onStopListener?.invoke()
},
onAutoComplete = { suggestion ->
onEditSuggestionListener?.invoke(suggestion.editSuggestion!!)
},
onScroll = { hideKeyboard() },
profiler = context.components.core.engine.profiler,
)
}
}
override fun addProviders(vararg providers: AwesomeBar.SuggestionProvider) {
val newProviders = this.providers.value.toMutableList()
newProviders.addAll(providers)
this.providers.value = newProviders
}
override fun containsProvider(provider: AwesomeBar.SuggestionProvider): Boolean {
return providers.value.any { current -> current.id == provider.id }
}
override fun onInputChanged(text: String) {
this.text.value = text
}
override fun removeAllProviders() {
providers.value = emptyList()
}
override fun removeProviders(vararg providers: AwesomeBar.SuggestionProvider) {
val newProviders = this.providers.value.toMutableList()
newProviders.removeAll(providers)
this.providers.value = newProviders
}
override fun setOnEditSuggestionListener(listener: (String) -> Unit) {
onEditSuggestionListener = listener
}
override fun setOnStopListener(listener: () -> Unit) {
onStopListener = listener
}
}

View File

@ -0,0 +1,117 @@
/* 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.toolbar
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.library.bookmarks.BookmarkSearchFragmentState
/**
* Interface for the Toolbar Interactor. This interface is implemented by objects that want
* to respond to user interaction on the [ToolbarView]
*/
interface ToolbarInteractor {
/**
* Called when a user removes focus from the [ToolbarView]
*/
fun onEditingCanceled()
/**
* Called whenever the text inside the [ToolbarView] changes
* @param text the current text displayed by [ToolbarView]
*/
fun onTextChanged(text: String)
}
/**
* View that contains and configures the BrowserToolbar to only be used in its editing mode.
*/
@Suppress("LongParameterList")
class ToolbarView(
private val context: Context,
private val interactor: ToolbarInteractor,
private val isPrivate: Boolean,
val view: BrowserToolbar,
) {
@VisibleForTesting
internal var isInitialized = false
init {
view.apply {
editMode()
background = AppCompatResources.getDrawable(
context, context.theme.resolveAttribute(R.attr.layer1)
)
edit.hint = context.getString(R.string.bookmark_search)
edit.colors = edit.colors.copy(
text = context.getColorFromAttr(R.attr.textPrimary),
hint = context.getColorFromAttr(R.attr.textSecondary),
suggestionBackground = ContextCompat.getColor(
context,
R.color.suggestion_highlight_color
),
clear = context.getColorFromAttr(R.attr.textPrimary)
)
edit.setUrlBackground(
AppCompatResources.getDrawable(context, R.drawable.search_url_background)
)
private = isPrivate
setOnUrlCommitListener {
hideKeyboard()
// We need to return false to not show display mode
false
}
setOnEditListener(object : mozilla.components.concept.toolbar.Toolbar.OnEditListener {
override fun onCancelEditing(): Boolean {
interactor.onEditingCanceled()
// We need to return false to not show display mode
return false
}
override fun onTextChanged(text: String) {
url = text
interactor.onTextChanged(text)
}
})
}
}
fun update(state: BookmarkSearchFragmentState) {
if (!isInitialized) {
view.url = state.query
view.setSearchTerms(state.query)
// We must trigger an onTextChanged so when search terms are set when transitioning to `editMode`
// we have the most up to date text
interactor.onTextChanged(view.url.toString())
view.editMode()
isInitialized = true
}
val bookmarkSearchIcon = AppCompatResources.getDrawable(context, R.drawable.ic_bookmarks_menu)
bookmarkSearchIcon?.let {
view.edit.setIcon(bookmarkSearchIcon, context.getString(R.string.bookmark_search))
}
}
}

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/search_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:background="?attr/scrimBackground">
<mozilla.components.browser.toolbar.BrowserToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="@dimen/browser_toolbar_height"
android:background="@drawable/toolbar_background_top"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed"
app:browserToolbarClearColor="?attr/textPrimary"
app:browserToolbarInsecureColor="?attr/textPrimary"
app:browserToolbarMenuColor="?attr/textPrimary"
app:browserToolbarProgressBarGravity="bottom"
app:browserToolbarSecureColor="?attr/textPrimary"
app:browserToolbarTrackingProtectionAndSecurityIndicatorSeparatorColor="?borderPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<ViewStub
android:id="@+id/search_suggestions_hint"
android:layout_width="0dp"
android:layout_height="0dp"
android:inflatedId="@id/search_suggestions_hint"
android:layout="@layout/search_suggestions_hint"
app:layout_constraintBottom_toTopOf="@id/search_hint_bottom_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintHeight_default="wrap"/>
<View
android:id="@+id/search_suggestions_hint_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?borderPrimary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/search_suggestions_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/search_hint_bottom_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="awesome_bar,pill_wrapper"/>
<org.mozilla.fenix.library.bookmarks.awesomebar.AwesomeBarWrapper
android:id="@+id/awesome_bar"
android:layout_width="0dp"
android:layout_height="0dp"
android:fadingEdge="horizontal"
android:fadingEdgeLength="40dp"
android:nestedScrollingEnabled="false"
android:requiresFadingEdge="vertical"
android:background="?attr/layer1"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/pill_wrapper"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/search_suggestions_hint" />
<ImageView
android:id="@+id/link_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/search_fragment_clipboard_item_horizontal_margin"
android:clickable="false"
android:focusable="false"
android:importantForAccessibility="no"
android:visibility="gone"
app:srcCompat="@drawable/ic_link"
tools:visibility="visible" />
<View
android:id="@+id/pill_wrapper_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?borderPrimary"
app:layout_constraintBottom_toTopOf="@id/pill_wrapper" />
<View
android:id="@+id/pill_wrapper"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?attr/layer1"
android:importantForAccessibility="no"
android:paddingStart="@dimen/search_fragment_pill_padding_start"
android:paddingTop="@dimen/search_fragment_pill_padding_vertical"
android:paddingEnd="@dimen/search_fragment_pill_padding_end"
android:paddingBottom="@dimen/search_fragment_pill_padding_vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,6 +4,13 @@
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/bookmark_search"
android:icon="@drawable/mozac_ic_search"
android:title="@string/bookmark_search"
app:iconTint="?attr/textPrimary"
app:showAsAction="ifRoom" />
<item
android:id="@+id/add_bookmark_folder"
android:icon="@drawable/ic_folder_new"

View File

@ -309,8 +309,19 @@
<action
android:id="@+id/action_bookmarkFragment_to_bookmarkAddFolderFragment"
app:destination="@id/bookmarkAddFolderFragment" />
<action
android:id="@+id/action_bookmarkFragment_to_bookmarkSearchDialogFragment"
app:destination="@id/bookmarkSearchDialogFragment"
app:popUpTo="@id/bookmarkSearchDialogFragment"
app:popUpToInclusive="true" />
</fragment>
<dialog
android:id="@+id/bookmarkSearchDialogFragment"
android:name="org.mozilla.fenix.library.bookmarks.BookmarkSearchDialogFragment"
tools:layout="@layout/fragment_bookmark_search_dialog">
</dialog>
<fragment
android:id="@+id/bookmarkEditFragment"
android:name="org.mozilla.fenix.library.bookmarks.edit.EditBookmarkFragment"

View File

@ -834,6 +834,8 @@
<string name="bookmark_deletion_multiple_snackbar_message_3">Deleting selected folders</string>
<!-- Bookmark undo button for deletion snackbar action -->
<string name="bookmark_undo_deletion">UNDO</string>
<!-- Text for the button to search all bookmarks -->
<string name="bookmark_search">Enter search terms</string>
<!-- Site Permissions -->
<!-- Button label that take the user to the Android App setting -->

View File

@ -465,6 +465,18 @@ class BookmarkControllerTest {
}
}
@Test
fun `WHEN onSearch is called with BookmarkFragment THEN navigate to BookmarkSearchDialogFragment`() {
val controller = createController()
controller.handleSearch()
verify {
navController.navigate(
BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkSearchDialogFragment()
)
}
}
@Suppress("LongParameterList")
private fun createController(
loadBookmarkNode: suspend (String) -> BookmarkNode? = { _ -> null },

View File

@ -206,4 +206,13 @@ class BookmarkFragmentInteractorTest {
bookmarkController.handleRequestSync()
}
}
@Test
fun `WHEN onSearch is called THEN call controller handleSearch`() {
interactor.onSearch()
verify {
bookmarkController.handleSearch()
}
}
}

View File

@ -0,0 +1,85 @@
/* 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 io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.concept.engine.EngineSession
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
class BookmarkSearchControllerTest {
@MockK(relaxed = true) private lateinit var activity: HomeActivity
@MockK(relaxed = true) private lateinit var store: BookmarkSearchFragmentStore
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun `WHEN editing is cancelled THEN clearToolbarFocus is called`() = runBlockingTest {
var clearToolbarFocusInvoked = false
createController(
clearToolbarFocus = {
clearToolbarFocusInvoked = true
}
).handleEditingCancelled()
assertTrue(clearToolbarFocusInvoked)
}
@Test
fun `WHEN text changed THEN update query action is dispatched`() {
val text = "fenix"
createController().handleTextChanged(text)
verify { store.dispatch(BookmarkSearchFragmentAction.UpdateQuery(text)) }
}
@Test
fun `WHEN text is changed to empty THEN update query action is dispatched`() {
val text = ""
createController().handleTextChanged(text)
verify { store.dispatch(BookmarkSearchFragmentAction.UpdateQuery(text)) }
}
@Test
fun `WHEN url is tapped THEN openToBrowserAndLoad is called`() {
val url = "https://www.google.com/"
val flags = EngineSession.LoadUrlFlags.none()
createController().handleUrlTapped(url, flags)
createController().handleUrlTapped(url)
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarkSearchDialog,
flags = flags
)
}
}
private fun createController(
clearToolbarFocus: () -> Unit = { },
): BookmarkSearchDialogController {
return BookmarkSearchDialogController(
activity = activity,
fragmentStore = store,
clearToolbarFocus = clearToolbarFocus,
)
}
}

View File

@ -0,0 +1,50 @@
/* 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 io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Test
class BookmarkSearchDialogInteractorTest {
lateinit var searchController: BookmarkSearchDialogController
lateinit var interactor: BookmarkSearchDialogInteractor
@Before
fun setup() {
searchController = mockk(relaxed = true)
interactor = BookmarkSearchDialogInteractor(
searchController
)
}
@Test
fun onEditingCanceled() = runBlockingTest {
interactor.onEditingCanceled()
verify {
searchController.handleEditingCancelled()
}
}
@Test
fun onTextChanged() {
interactor.onTextChanged("test")
verify { searchController.handleTextChanged("test") }
}
@Test
fun onUrlTapped() {
interactor.onUrlTapped("test")
verify {
searchController.handleUrlTapped("test")
}
}
}

View File

@ -0,0 +1,34 @@
/* 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 kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class BookmarkSearchFragmentStoreTest {
@Test
fun `GIVEN createInitialBookmarkSearchFragmentState THEN query is empty`() {
val expected = BookmarkSearchFragmentState(query = "")
assertEquals(
expected,
createInitialBookmarkSearchFragmentState()
)
}
@Test
fun updateQuery() = runBlocking {
val initialState = BookmarkSearchFragmentState(query = "")
val store = BookmarkSearchFragmentStore(initialState)
val query = "test query"
store.dispatch(BookmarkSearchFragmentAction.UpdateQuery(query)).join()
assertNotSame(initialState, store.state)
assertEquals(query, store.state.query)
}
}