diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt index 24140e924..8a579b2e3 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt @@ -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 { diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt index 67c82c79b..cd2893f26 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt @@ -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() 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) { + 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 diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt index fd50e54e3..d2e308fdf 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt @@ -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() } diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelector.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelector.kt new file mode 100644 index 000000000..0eaf9c126 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelector.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenu.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenu.kt new file mode 100644 index 000000000..4a88c9e22 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenu.kt @@ -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 { + 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) + } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarAction.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarAction.kt new file mode 100644 index 000000000..65ea7d34d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarAction.kt @@ -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(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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt index b29ed9aee..ea26df95c 100644 --- a/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt @@ -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) } /** diff --git a/app/src/main/res/drawable/ic_chevron_down_6.xml b/app/src/main/res/drawable/ic_chevron_down_6.xml new file mode 100644 index 000000000..dcbfdd6e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down_6.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/search_selector.xml b/app/src/main/res/layout/search_selector.xml new file mode 100644 index 000000000..9101fce4f --- /dev/null +++ b/app/src/main/res/layout/search_selector.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4aea0407..11f05922d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,6 +238,8 @@ Search %s Search directly from the address bar + + Search settings diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5632b3a39..9caa5576d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -697,4 +697,10 @@ @android:color/transparent true + + diff --git a/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt b/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt index b6d712c37..2203f488d 100644 --- a/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt @@ -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 = { }, diff --git a/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt index 897fd669a..3231b5f4e 100644 --- a/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt @@ -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) + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt b/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt new file mode 100644 index 000000000..796e7e3fa --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt @@ -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) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt b/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt new file mode 100644 index 000000000..d199796e1 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt @@ -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) + } + } +}