For #24297 - Add a search engine menu that shows the search engine and search settings

pull/543/head
Gabriel Luong 2 years ago committed by mergify[bot]
parent dfe23e8b77
commit 8fe9c5bdd1

@ -28,6 +28,7 @@ import org.mozilla.fenix.components.metrics.MetricsUtils
import org.mozilla.fenix.crashes.CrashListActivity
import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.utils.Settings
@ -47,6 +48,11 @@ interface SearchController {
fun handleSearchShortcutsButtonClicked()
fun handleCameraPermissionsNeeded()
fun handleSearchEngineSuggestionClicked(searchEngine: SearchEngine)
/**
* @see [ToolbarInteractor.onMenuItemTapped]
*/
fun handleMenuItemTapped(item: SearchSelectorMenu.Item)
}
@Suppress("TooManyFunctions", "LongParameterList")
@ -234,6 +240,13 @@ class SearchDialogController(
handleSearchShortcutEngineSelected(searchEngine)
}
override fun handleMenuItemTapped(item: SearchSelectorMenu.Item) {
when (item) {
SearchSelectorMenu.Item.SearchSettings -> handleClickSearchEngineSettings()
is SearchSelectorMenu.Item.SearchEngine -> handleSearchShortcutEngineSelected(item.searchEngine)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun buildDialog(): AlertDialog.Builder {
return AlertDialog.Builder(activity).apply {

@ -31,6 +31,7 @@ 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.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
@ -39,8 +40,12 @@ import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
import mozilla.components.concept.menu.candidate.TextMenuCandidate
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.feature.qr.QrFeature
import mozilla.components.lib.state.ext.consumeFlow
@ -74,6 +79,8 @@ import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.search.awesomebar.AwesomeBarView
import org.mozilla.fenix.search.awesomebar.toSearchProviderState
import org.mozilla.fenix.search.toolbar.IncreasedTapAreaActionDecorator
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.search.toolbar.SearchSelectorToolbarAction
import org.mozilla.fenix.search.toolbar.ToolbarView
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.widget.VoiceSearchActivity
@ -85,17 +92,26 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private var _binding: FragmentSearchDialogBinding? = null
private val binding get() = _binding!!
private var voiceSearchButtonAlreadyAdded: Boolean = false
private var qrButtonAlreadyAdded = false
private lateinit var interactor: SearchDialogInteractor
private lateinit var store: SearchDialogFragmentStore
private lateinit var toolbarView: ToolbarView
private lateinit var inlineAutocompleteEditText: InlineAutocompleteEditText
private lateinit var awesomeBarView: AwesomeBarView
private val searchSelectorMenu by lazy {
SearchSelectorMenu(
context = requireContext(),
interactor = interactor
)
}
private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
private var dialogHandledAction = false
private var qrButtonAlreadyAdded = false
private var searchSelectorAlreadyAdded = false
private var voiceSearchButtonAlreadyAdded = false
override fun onStart() {
super.onStart()
@ -243,6 +259,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
.ifChanged()
.collect { search ->
store.dispatch(SearchFragmentAction.UpdateSearchState(search))
updateSearchSelectorMenu(search.searchEngines)
}
}
@ -374,9 +392,12 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
updateToolbarContentDescription(it.searchEngineSource)
toolbarView.update(it)
awesomeBarView.update(it)
if (showUnifiedSearchFeature) {
addSearchSelector()
addQrButton(it)
}
addVoiceSearchButton(it)
}
}
@ -645,6 +666,43 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
}
}
/**
* Updates the search selector menu with the given list of available search engines.
*
* @param searchEngines List of [SearchEngine] to display.
*/
private fun updateSearchSelectorMenu(searchEngines: List<SearchEngine>) {
searchSelectorMenu.menuController.submitList(
searchSelectorMenu.menuItems() +
searchEngines
.reversed()
.map {
TextMenuCandidate(
text = it.name,
start = DrawableMenuIcon(
drawable = it.icon.toDrawable(resources)
)
) {
interactor.onMenuItemTapped(SearchSelectorMenu.Item.SearchEngine(it))
}
}
)
}
private fun addSearchSelector() {
if (searchSelectorAlreadyAdded) return
toolbarView.view.addEditActionStart(
SearchSelectorToolbarAction(
store = store,
menu = searchSelectorMenu,
viewLifecycleOwner = viewLifecycleOwner
)
)
searchSelectorAlreadyAdded = true
}
private fun addVoiceSearchButton(searchFragmentState: SearchFragmentState) {
if (voiceSearchButtonAlreadyAdded) return
val searchEngine = searchFragmentState.searchEngineSource.searchEngine

@ -7,6 +7,7 @@ package org.mozilla.fenix.search
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.search.toolbar.ToolbarInteractor
/**
@ -58,6 +59,10 @@ class SearchDialogInteractor(
searchController.handleExistingSessionSelected(tabId)
}
override fun onMenuItemTapped(item: SearchSelectorMenu.Item) {
searchController.handleMenuItemTapped(item)
}
fun onCameraPermissionsNeeded() {
searchController.handleCameraPermissionsNeeded()
}

@ -0,0 +1,29 @@
/* 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.toolbar
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import org.mozilla.fenix.databinding.SearchSelectorBinding
/**
* A search selector menu used in the Browser Toolbar in Edit mode.
*/
internal class SearchSelector @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : RelativeLayout(context, attrs, defStyle) {
private val binding = SearchSelectorBinding.inflate(LayoutInflater.from(context), this)
fun setIcon(icon: Drawable, contentDescription: String) {
binding.icon.setImageDrawable(icon)
binding.icon.contentDescription = contentDescription
}
}

@ -0,0 +1,67 @@
/* 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.toolbar
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import mozilla.components.browser.menu2.BrowserMenuController
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.concept.menu.MenuController
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
import mozilla.components.concept.menu.candidate.TextMenuCandidate
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.R
typealias MozSearchEngine = SearchEngine
/**
* A popup menu composed of [SearchSelectorMenu.Item] objects.
*
* @property context [Context] used for various Android interactions.
* @property interactor [ToolbarInteractor] for handling menu item interactions.
*/
class SearchSelectorMenu(
private val context: Context,
private val interactor: ToolbarInteractor
) {
/**
* Items that will appear in the search selector menu.
*/
sealed class Item {
/**
* The menu item to navigate to the search settings.
*/
object SearchSettings : Item()
/**
* The menu item to display a search engine.
*
* @param searchEngine The [SearchEngine] that was selected.
*/
data class SearchEngine(val searchEngine: MozSearchEngine) : Item()
}
val menuController: MenuController by lazy { BrowserMenuController() }
@VisibleForTesting
internal fun menuItems(): List<TextMenuCandidate> {
return listOf(
TextMenuCandidate(
text = context.getString(R.string.search_settings_menu_item),
start = DrawableMenuIcon(
drawable = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_settings
),
tint = context.getColorFromAttr(R.attr.textPrimary)
)
) {
interactor.onMenuItemTapped(Item.SearchSettings)
}
)
}
}

@ -0,0 +1,81 @@
/* 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.toolbar
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.concept.toolbar.Toolbar
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R
import org.mozilla.fenix.search.SearchDialogFragmentStore
import java.lang.ref.WeakReference
/**
* A [Toolbar.Action] implementation that shows a [SearchSelector].
*
* @property store [SearchDialogFragmentStore] containing the complete state of the search dialog.
* @property menu An instance of [SearchSelectorMenu] to display a popup menu for the search
* selections.
* @property viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
class SearchSelectorToolbarAction(
private val store: SearchDialogFragmentStore,
private val menu: SearchSelectorMenu,
private val viewLifecycleOwner: LifecycleOwner
) : Toolbar.Action {
private var reference = WeakReference<SearchSelector>(null)
override fun createView(parent: ViewGroup): View {
val context = parent.context
store.flowScoped(viewLifecycleOwner) { flow ->
flow.map { state -> state.searchEngineSource.searchEngine }
.ifChanged()
.collect { searchEngine ->
searchEngine?.let {
updateIcon(context, it)
}
}
}
return SearchSelector(context).apply {
reference = WeakReference(this)
setOnClickListener {
menu.menuController.show(anchor = it)
}
setBackgroundResource(
context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
)
}
}
override fun bind(view: View) = Unit
private fun updateIcon(context: Context, searchEngine: SearchEngine) {
val iconSize =
context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
val scaledIcon = Bitmap.createScaledBitmap(
searchEngine.icon,
iconSize,
iconSize,
true
)
val icon = BitmapDrawable(context.resources, scaledIcon)
reference.get()?.setIcon(icon, searchEngine.name)
}
}

@ -24,14 +24,15 @@ import org.mozilla.fenix.utils.Settings
/**
* Interface for the Toolbar Interactor. This interface is implemented by objects that want
* to respond to user interaction on the [ToolbarView]
* to respond to user interaction on the [ToolbarView].
*/
interface ToolbarInteractor {
/**
* Called when a user hits the return key while [ToolbarView] has focus.
* @param url the text inside the [ToolbarView] when committed
* @param fromHomeScreen true if the toolbar has been opened from home screen
*
* @param url The text inside the [ToolbarView] when committed.
* @param fromHomeScreen True if the toolbar has been opened from home screen.
*/
fun onUrlCommitted(url: String, fromHomeScreen: Boolean = false)
@ -42,9 +43,17 @@ interface ToolbarInteractor {
/**
* Called whenever the text inside the [ToolbarView] changes
* @param text the current text displayed by [ToolbarView]
*
* @param text The current text displayed by [ToolbarView].
*/
fun onTextChanged(text: String)
/**
* Called when an user taps on a search selector menu item.
*
* @param item The [SearchSelectorMenu.Item] that was tapped.
*/
fun onMenuItemTapped(item: SearchSelectorMenu.Item)
}
/**

@ -0,0 +1,13 @@
<?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/. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="6dp"
android:height="6dp"
android:viewportWidth="6"
android:viewportHeight="6">
<path
android:fillColor="?attr/textPrimary"
android:pathData="M0.8536,1.6465C0.6583,1.4512 0.3417,1.4512 0.1464,1.6465C-0.0488,1.8417 -0.0488,2.1583 0.1464,2.3535L0.8536,1.6465ZM3,4.5L2.6465,4.8535H3.3535L3,4.5ZM5.8535,2.3535C6.0488,2.1583 6.0488,1.8417 5.8535,1.6465C5.6583,1.4512 5.3417,1.4512 5.1465,1.6465L5.8535,2.3535ZM0.1464,2.3535L2.6465,4.8535L3.3535,4.1465L0.8536,1.6465L0.1464,2.3535ZM3.3535,4.8535L5.8535,2.3535L5.1465,1.6465L2.6465,4.1465L3.3535,4.8535Z" />
</vector>

@ -0,0 +1,53 @@
<?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/. -->
<merge 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"
tools:layout_height="wrap_content"
tools:layout_width="wrap_content">
<com.google.android.material.card.MaterialCardView
android:id="@+id/search_selector"
android:layout_width="40dp"
android:layout_height="28dp"
android:layout_marginTop="14dp"
android:layout_marginHorizontal="8dp"
app:cardBackgroundColor="?attr/layer2"
app:cardCornerRadius="4dp"
app:cardElevation="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/tab_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginVertical="2dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="center"
android:layout_gravity="center"
app:shapeAppearanceOverlay="@style/SearchSelectorIconStyle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_search" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/arrow"
android:layout_width="6dp"
android:layout_height="6dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_chevron_down_6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</merge>

@ -238,6 +238,8 @@
<string name="search_engine_suggestions_title">Search %s</string>
<!-- Search engine suggestion description text -->
<string name="search_engine_suggestions_description">Search directly from the address bar</string>
<!-- Menu option in the search selector menu to open the search settings -->
<string name="search_settings_menu_item">Search settings</string>
<!-- Home onboarding -->
<!-- Onboarding home screen dialog title text. Firefox is intentionally hardcoded. -->

@ -697,4 +697,10 @@
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
<style name="SearchSelectorIconStyle">
<item name="cornerFamily">rounded</item>
<item name="elevation">0dp</item>
<item name="cornerSize">2dp</item>
</style>
</resources>

@ -48,6 +48,7 @@ import org.mozilla.fenix.components.metrics.MetricsUtils
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.search.SearchDialogFragmentDirections.Companion.actionGlobalAddonsManagementFragment
import org.mozilla.fenix.search.SearchDialogFragmentDirections.Companion.actionGlobalSearchEngineFragment
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.utils.Settings
@ -395,6 +396,15 @@ class SearchDialogControllerTest {
verify { dialogBuilder.show() }
}
@Test
fun `GIVEN search settings menu item WHEN search selector menu item is tapped THEN show search engine settings`() {
val controller = spyk(createController())
controller.handleMenuItemTapped(SearchSelectorMenu.Item.SearchSettings)
verify { controller.handleClickSearchEngineSettings() }
}
private fun createController(
clearToolbarFocus: () -> Unit = { },
focusToolbar: () -> Unit = { },

@ -10,6 +10,7 @@ import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.browser.state.search.SearchEngine
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
class SearchDialogInteractorTest {
@ -112,4 +113,15 @@ class SearchDialogInteractorTest {
searchController.handleCameraPermissionsNeeded()
}
}
@Test
fun onMenuItemTapped() {
val item = SearchSelectorMenu.Item.SearchSettings
interactor.onMenuItemTapped(item)
verify {
searchController.handleMenuItemTapped(item)
}
}
}

@ -0,0 +1,40 @@
/* 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.toolbar
import android.view.LayoutInflater
import androidx.appcompat.content.res.AppCompatResources
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.SearchSelectorBinding
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class SearchSelectorTest {
private lateinit var searchSelector: SearchSelector
private lateinit var binding: SearchSelectorBinding
@Before
fun setup() {
searchSelector = SearchSelector(testContext)
binding = SearchSelectorBinding.inflate(LayoutInflater.from(testContext), searchSelector)
}
@Test
fun `WHEN set icon is called THEN an icon and its content description are set`() {
val icon = AppCompatResources.getDrawable(testContext, R.drawable.ic_search)!!
val contentDescription = "contentDescription"
searchSelector.setIcon(icon, contentDescription)
assertEquals(icon, binding.icon.drawable)
assertEquals(contentDescription, binding.icon.contentDescription)
}
}

@ -0,0 +1,71 @@
/* 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.toolbar
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.search.SearchDialogFragmentStore
@RunWith(FenixRobolectricTestRunner::class)
class SearchSelectorToolbarActionTest {
@MockK(relaxed = true)
private lateinit var store: SearchDialogFragmentStore
@MockK(relaxed = true)
private lateinit var menu: SearchSelectorMenu
private lateinit var lifecycleOwner: MockedLifecycleOwner
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
val lifecycleRegistry = LifecycleRegistry(this).apply {
currentState = initialState
}
override fun getLifecycle(): Lifecycle = lifecycleRegistry
}
@Before
fun setup() {
MockKAnnotations.init(this)
lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
}
@Test
fun `WHEN search selector toolbar action is clicked THEN the search selector menu is shown`() {
val action = spyk(
SearchSelectorToolbarAction(
store = store,
menu = menu,
viewLifecycleOwner = lifecycleOwner
)
)
val view = action.createView(LinearLayout(testContext) as ViewGroup) as SearchSelector
view.performClick()
verify {
menu.menuController.show(view)
}
}
}
Loading…
Cancel
Save