Bug 1826473 – add new search engine settings screens

fenix/116.0
mike a 1 year ago committed by mergify[bot]
parent 76c1c11bfd
commit e73a630bd5

@ -38,6 +38,7 @@ class FeatureSettingsHelperDelegate : FeatureSettingsHelper {
isOpenInAppBannerEnabled = settings.shouldShowOpenInAppBanner,
etpPolicy = getETPPolicy(settings),
tabsTrayRewriteEnabled = settings.enableTabsTrayToCompose,
newSearchSettingsEnabled = false,
)
/**
@ -92,6 +93,7 @@ class FeatureSettingsHelperDelegate : FeatureSettingsHelper {
settings.userOptOutOfReEngageCookieBannerDialog = !featureFlags.isCookieBannerReductionDialogEnabled
settings.shouldShowOpenInAppBanner = featureFlags.isOpenInAppBannerEnabled
settings.enableTabsTrayToCompose = featureFlags.tabsTrayRewriteEnabled
settings.enableUnifiedSearchSettingsUI = featureFlags.newSearchSettingsEnabled
setETPPolicy(featureFlags.etpPolicy)
}
}
@ -112,6 +114,7 @@ private data class FeatureFlags(
var isOpenInAppBannerEnabled: Boolean,
var etpPolicy: ETPPolicy,
var tabsTrayRewriteEnabled: Boolean,
var newSearchSettingsEnabled: Boolean,
)
internal fun getETPPolicy(settings: Settings): ETPPolicy {

@ -34,6 +34,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromAddNewDeviceFragment(R.id.addNewDeviceFragment),
FromAddSearchEngineFragment(R.id.addSearchEngineFragment),
FromEditCustomSearchEngineFragment(R.id.editCustomSearchEngineFragment),
FromSaveSearchEngineFragment(R.id.saveSearchEngineFragment),
FromAddonDetailsFragment(R.id.addonDetailsFragment),
FromStudiesFragment(R.id.studiesFragment),
FromAddonPermissionsDetailsFragment(R.id.addonPermissionsDetailFragment),

@ -56,4 +56,10 @@ object FeatureFlags {
* Enables compose on the top sites.
*/
const val composeTopSites = false
/**
* Enables new search settings UI with two extra fragments, for managing the default engine
* and managing search shortcuts in the quick search menu.
*/
val unifiedSearchSettings = Config.channel.isNightlyOrDebug
}

@ -141,6 +141,7 @@ import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirecti
import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.dialog.CookieBannerReEngagementDialogUtils
import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.SaveSearchEngineFragmentDirections
import org.mozilla.fenix.settings.studies.StudiesFragmentDirections
import org.mozilla.fenix.settings.wallpaper.WallpaperSettingsFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections
@ -941,6 +942,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
AddSearchEngineFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromEditCustomSearchEngineFragment ->
EditCustomSearchEngineFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromSaveSearchEngineFragment ->
SaveSearchEngineFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromAddonDetailsFragment ->
AddonDetailsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromAddonPermissionsDetailsFragment ->

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
import mozilla.components.concept.menu.candidate.TextMenuCandidate
@ -18,6 +17,7 @@ import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R
import org.mozilla.fenix.search.ext.searchEngineShortcuts
import org.mozilla.fenix.search.toolbar.SearchSelectorInteractor
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
@ -35,7 +35,7 @@ class SearchSelectorMenuBinding(
flow.map { state -> state.search }
.ifChanged()
.collect { search ->
updateSearchSelectorMenu(search.searchEngines)
updateSearchSelectorMenu(search.searchEngineShortcuts)
}
}

@ -92,6 +92,7 @@ import org.mozilla.fenix.ext.secure
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.ext.searchEngineShortcuts
import org.mozilla.fenix.search.toolbar.IncreasedTapAreaActionDecorator
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.search.toolbar.SearchSelectorToolbarAction
@ -339,7 +340,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
),
)
updateSearchSelectorMenu(search.searchEngines)
updateSearchSelectorMenu(search.searchEngineShortcuts)
}
}
@ -512,7 +513,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
if (selectedSearchEngineId == null) return
val searchState = requireComponents.core.store.state.search
searchState.searchEngines.firstOrNull {
searchState.searchEngineShortcuts.firstOrNull {
it.id == selectedSearchEngineId
}?.let { selectedSearchEngine ->
if (selectedSearchEngine != searchState.selectedOrDefaultSearchEngine) {

@ -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.search.ext
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.availableSearchEngines
/**
* The list of search engine shortcuts to be available for quick search menu.
*/
val SearchState.searchEngineShortcuts: List<SearchEngine>
get() = (
regionSearchEngines + additionalSearchEngines + availableSearchEngines +
customSearchEngines + applicationSearchEngines
).filter {
!disabledSearchEngineIds.contains(it.id)
}

@ -0,0 +1,43 @@
/* 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.settings.search
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.showToolbar
/**
* A [Fragment] that allows user to set the default search engine.
*/
class DefaultSearchEngineFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.default_search_engine_preferences, rootKey)
}
override fun onResume() {
super.onResume()
view?.hideKeyboard()
showToolbar(getString(R.string.preferences_default_search_engine))
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
getPreferenceKey(R.string.pref_key_add_search_engine) -> {
val directions = DefaultSearchEngineFragmentDirections
.actionDefaultEngineFragmentToSaveSearchEngineFragment(null)
findNavController().navigate(directions)
}
}
return super.onPreferenceTreeClick(preference)
}
}

@ -15,7 +15,7 @@ import android.widget.CompoundButton
import android.widget.LinearLayout
import android.widget.RadioGroup
import androidx.core.view.isVisible
import androidx.navigation.Navigation
import androidx.navigation.Navigation.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -82,19 +82,28 @@ class RadioSearchEngineListPreference @JvmOverloads constructor(
it.isGeneral
}.size == 1
state.searchEngines.filter { engine ->
engine.type != SearchEngine.Type.APPLICATION
if (context.settings().enableUnifiedSearchSettingsUI) {
engine.type != SearchEngine.Type.APPLICATION && engine.isGeneral
} else {
engine.type != SearchEngine.Type.APPLICATION
}
}.forEach { engine ->
val isLastSearchEngineAvailable =
state.searchEngines.count { it.type != SearchEngine.Type.APPLICATION } > 1
val allowDeletion = if (context.settings().enableUnifiedSearchSettingsUI) {
engine.type == SearchEngine.Type.CUSTOM
} else {
if (context.settings().showUnifiedSearchFeature) {
isLastSearchEngineAvailable && !(engine.isGeneral && isLastGeneralOrCustomSearchEngine)
} else {
isLastSearchEngineAvailable
}
}
val searchEngineView = makeButtonFromSearchEngine(
engine = engine,
layoutInflater = layoutInflater,
res = context.resources,
allowDeletion = if (context.settings().showUnifiedSearchFeature) {
isLastSearchEngineAvailable && !(engine.isGeneral && isLastGeneralOrCustomSearchEngine)
} else {
isLastSearchEngineAvailable
},
allowDeletion = allowDeletion,
isSelected = engine == state.selectedOrDefaultSearchEngine,
)
@ -163,10 +172,14 @@ class RadioSearchEngineListPreference @JvmOverloads constructor(
}
private fun editCustomSearchEngine(view: View, engine: SearchEngine) {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.id)
Navigation.findNavController(view).navigate(directions)
val directions = if (view.context.settings().enableUnifiedSearchSettingsUI) {
DefaultSearchEngineFragmentDirections
.actionDefaultEngineFragmentToSaveSearchEngineFragment(engine.id)
} else {
SearchEngineFragmentDirections
.actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.id)
}
findNavController(view).navigate(directions)
}
private fun deleteSearchEngine(

@ -0,0 +1,192 @@
/* 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.settings.search
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.icons.IconRequest
import mozilla.components.feature.search.ext.createSearchEngine
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.FragmentSaveSearchEngineBinding
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SupportUtils
/**
* A [Fragment] that allows user to add a new search engine.
*/
@SuppressWarnings("LargeClass", "TooManyFunctions")
class SaveSearchEngineFragment : Fragment(R.layout.fragment_save_search_engine) {
private val args by navArgs<SaveSearchEngineFragmentArgs>()
private val searchEngine by lazy {
requireComponents.core.store.state.search.customSearchEngines.find { engine ->
engine.id == args.searchEngineIdentifier
}
}
private var _binding: FragmentSaveSearchEngineBinding? = null
private val binding get() = _binding!!
private val inputListener = object : TextWatcher {
override fun afterTextChanged(editable: Editable) {
val bothFieldsHaveInput = binding.editEngineName.text?.isNotBlank() == true &&
binding.editSearchString.text?.isNotBlank() == true
binding.saveButton.isEnabled = bothFieldsHaveInput
}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) =
Unit
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentSaveSearchEngineBinding.bind(view)
binding.saveButton.apply {
isEnabled = false
setOnClickListener { createCustomEngine() }
}
binding.editEngineName.addTextChangedListener(inputListener)
binding.editSearchString.addTextChangedListener(inputListener)
searchEngine?.let {
val url = it.resultUrls[0]
binding.editEngineName.setText(it.name)
binding.editSearchString.setText(url.toEditableUrl())
}
binding.customSearchEnginesLearnMoreWrapper.setOnClickListener {
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getSumoURLForTopic(
requireContext(),
SupportUtils.SumoTopic.CUSTOM_SEARCH_ENGINES,
),
newTab = true,
from = BrowserDirection.FromSaveSearchEngineFragment,
)
}
}
override fun onResume() {
super.onResume()
if (args.searchEngineIdentifier != null) {
showToolbar(getString(R.string.search_engine_edit_custom_search_engine_title))
} else {
showToolbar(getString(R.string.search_engine_add_custom_search_engine_title))
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
@Suppress("ComplexMethod")
private fun createCustomEngine() {
binding.customSearchEngineNameField.error = ""
binding.customSearchEngineSearchStringField.error = ""
val name = binding.editEngineName.text?.toString()?.trim() ?: ""
val searchString = binding.editSearchString.text?.toString() ?: ""
if (checkForErrors(name, searchString)) {
return
}
viewLifecycleOwner.lifecycleScope.launch(Main) {
val result = withContext(IO) {
SearchStringValidator.isSearchStringValid(
requireComponents.core.client,
searchString,
)
}
when (result) {
SearchStringValidator.Result.CannotReach -> {
binding.customSearchEngineSearchStringField.error = resources
.getString(R.string.search_add_custom_engine_error_cannot_reach, name)
}
SearchStringValidator.Result.Success -> {
val searchEngine = createSearchEngine(
name,
searchString.toSearchUrl(),
requireComponents.core.icons.loadIcon(IconRequest(searchString))
.await().bitmap,
isGeneral = true,
)
requireComponents.useCases.searchUseCases.addSearchEngine(searchEngine)
val successMessage = resources
.getString(R.string.search_add_custom_engine_success_message, name)
view?.also {
FenixSnackbar.make(
view = it,
duration = FenixSnackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false,
)
.setText(successMessage)
.show()
}
findNavController().popBackStack()
}
}
}
}
private fun checkForErrors(name: String, searchString: String): Boolean {
return when {
name.isEmpty() -> {
binding.customSearchEngineNameField.error = resources
.getString(R.string.search_add_custom_engine_error_empty_name)
true
}
searchString.isEmpty() -> {
binding.customSearchEngineSearchStringField.error =
resources.getString(R.string.search_add_custom_engine_error_empty_search_string)
true
}
!searchString.contains("%s") -> {
binding.customSearchEngineSearchStringField.error =
resources.getString(R.string.search_add_custom_engine_error_missing_template)
true
}
else -> false
}
}
}
private fun String.toEditableUrl(): String {
return replace("{searchTerms}", "%s")
}
private fun String.toSearchUrl(): String {
return replace("%s", "{searchTerms}")
}

@ -11,8 +11,10 @@ import androidx.preference.CheckBoxPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
@ -22,8 +24,14 @@ import org.mozilla.gecko.search.SearchWidgetProvider
class SearchEngineFragment : PreferenceFragmentCompat() {
private var unifiedSearchUI = false
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.search_preferences, rootKey)
unifiedSearchUI = requireContext().settings().enableUnifiedSearchSettingsUI
setPreferencesFromResource(
if (unifiedSearchUI) R.xml.search_settings_preferences else R.xml.search_preferences,
rootKey,
)
view?.hideKeyboard()
}
@ -32,6 +40,13 @@ class SearchEngineFragment : PreferenceFragmentCompat() {
view?.hideKeyboard()
showToolbar(getString(R.string.preferences_search))
if (unifiedSearchUI) {
with(requirePreference<Preference>(R.string.pref_key_default_search_engine)) {
summary =
requireContext().components.core.store.state.search.selectedOrDefaultSearchEngine?.name
}
}
val searchSuggestionsPreference =
requirePreference<SwitchPreference>(R.string.pref_key_show_search_suggestions).apply {
isChecked = context.settings().shouldShowSearchSuggestions
@ -113,6 +128,16 @@ class SearchEngineFragment : PreferenceFragmentCompat() {
.actionSearchEngineFragmentToAddSearchEngineFragment()
findNavController().navigate(directions)
}
getPreferenceKey(R.string.pref_key_default_search_engine) -> {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToDefaultEngineFragment()
findNavController().navigate(directions)
}
getPreferenceKey(R.string.pref_key_manage_search_shortcuts) -> {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToSearchShortcutsFragment()
findNavController().navigate(directions)
}
}
return super.onPreferenceTreeClick(preference)

@ -0,0 +1,316 @@
/* 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.settings.search
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Checkbox
import androidx.compose.material.CheckboxDefaults
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.availableSearchEngines
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.observeAsComposableState
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ContextualMenu
import org.mozilla.fenix.compose.MenuItem
import org.mozilla.fenix.compose.annotation.LightDarkPreview
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Top-level UI for search shortcuts settings.
*
* @param categoryTitle is used for displaying a title above the search shortcut list.
* @param store [BrowserStore] used to listen for changes to [SearchState].
* @param onCheckboxClicked Invoked when the user clicks on the checkbox of a search engine item.
* @param onEditEngineClicked Invoked when the user clicks on the edit item of the three dot menu.
* @param onDeleteEngineClicked Invoked when the user clicks on the delete item of the three dot menu.
* @param onAddEngineClicked Invoked when the user clicks on the add search engine button.
*/
@Suppress("LongParameterList")
@Composable
fun SearchEngineShortcuts(
categoryTitle: String,
store: BrowserStore,
onCheckboxClicked: (SearchEngine, Boolean) -> Unit,
onEditEngineClicked: (SearchEngine) -> Unit,
onDeleteEngineClicked: (SearchEngine) -> Unit,
onAddEngineClicked: () -> Unit,
) {
val searchState = store.observeAsComposableState { it.search }.value ?: SearchState()
val searchEngines = with(searchState) {
regionSearchEngines + additionalSearchEngines + availableSearchEngines + customSearchEngines
}
val disabledShortcutsIds = searchState.disabledSearchEngineIds
LazyColumn(
modifier = Modifier
.background(color = FirefoxTheme.colors.layer1)
.fillMaxSize(),
) {
item {
Title(title = categoryTitle)
Spacer(modifier = Modifier.height(12.dp))
}
items(
items = searchEngines,
key = { engine -> engine.id },
) {
SearchItem(
engine = it,
name = it.name,
isEnabled = !disabledShortcutsIds.contains(it.id),
onCheckboxClicked = onCheckboxClicked,
onEditEngineClicked = onEditEngineClicked,
onDeleteEngineClicked = onDeleteEngineClicked,
)
}
item {
AddEngineButton(onAddEngineClicked = onAddEngineClicked)
}
}
}
@Composable
private fun Title(title: String) {
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
color = FirefoxTheme.colors.textAccent,
fontWeight = FontWeight.W400,
modifier = Modifier.padding(horizontal = 16.dp),
style = FirefoxTheme.typography.headline8,
)
}
}
@Suppress("LongParameterList", "LongMethod")
@Composable
private fun SearchItem(
engine: SearchEngine,
name: String,
isEnabled: Boolean,
onCheckboxClicked: (SearchEngine, Boolean) -> Unit,
onEditEngineClicked: (SearchEngine) -> Unit,
onDeleteEngineClicked: (SearchEngine) -> Unit,
) {
val isMenuExpanded: MutableState<Boolean> = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.defaultMinSize(minHeight = 56.dp)
.padding(start = 4.dp),
) {
Checkbox(
modifier = Modifier.align(Alignment.CenterVertically),
checked = isEnabled,
onCheckedChange = { onCheckboxClicked.invoke(engine, it) },
colors = CheckboxDefaults.colors(
checkedColor = FirefoxTheme.colors.formSelected,
uncheckedColor = FirefoxTheme.colors.formDefault,
),
)
Spacer(modifier = Modifier.width(20.dp))
Image(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(24.dp),
bitmap = engine.icon.asImageBitmap(),
contentDescription = stringResource(
id = R.string.search_engine_icon_content_description,
engine.name,
),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(vertical = 8.dp)
.weight(1f),
text = name,
style = FirefoxTheme.typography.subtitle1,
color = FirefoxTheme.colors.textPrimary,
)
if (engine.type == SearchEngine.Type.CUSTOM) {
Box(
modifier = Modifier
.align(Alignment.CenterVertically),
) {
IconButton(
onClick = {
isMenuExpanded.value = true
},
) {
Icon(
painter = painterResource(id = R.drawable.ic_menu),
contentDescription = stringResource(id = R.string.content_description_menu),
tint = FirefoxTheme.colors.iconPrimary,
)
ContextualMenu(
showMenu = isMenuExpanded.value,
onDismissRequest = { isMenuExpanded.value = false },
menuItems = listOf(
MenuItem(
stringResource(R.string.search_engine_edit),
color = FirefoxTheme.colors.textWarning,
) {
onEditEngineClicked(engine)
},
MenuItem(
stringResource(R.string.search_engine_delete),
color = FirefoxTheme.colors.textWarning,
) {
onDeleteEngineClicked(engine)
},
),
offset = DpOffset(x = 0.dp, y = (-24).dp),
)
}
}
} else {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
@Composable
private fun AddEngineButton(
onAddEngineClicked: () -> Unit,
) {
Row(
modifier = Modifier
.defaultMinSize(minHeight = 56.dp)
.padding(start = 4.dp)
.clickable { onAddEngineClicked() },
) {
Spacer(modifier = Modifier.width(68.dp))
Icon(
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterVertically),
painter = painterResource(id = R.drawable.ic_new),
contentDescription = stringResource(
id = R.string.search_engine_add_custom_search_engine_button_content_description,
),
tint = FirefoxTheme.colors.iconPrimary,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(vertical = 8.dp)
.weight(1f),
text = stringResource(id = R.string.search_engine_add_custom_search_engine_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.subtitle1,
)
Spacer(modifier = Modifier.width(16.dp))
}
}
@LightDarkPreview
@Composable
private fun SearchEngineShortcutsPreview() {
FirefoxTheme {
SearchEngineShortcuts(
categoryTitle = stringResource(id = R.string.preferences_category_engines_in_search_menu),
store = BrowserStore(
initialState = BrowserState(
search = SearchState(
regionSearchEngines = generateFakeEnginesList(),
disabledSearchEngineIds = listOf("8", "9"),
),
),
),
onCheckboxClicked = { _, _ -> },
onEditEngineClicked = {},
onDeleteEngineClicked = {},
onAddEngineClicked = {},
)
}
}
private fun generateFakeEnginesList(): List<SearchEngine> {
val dummyBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
dummyBitmap.eraseColor(Color.BLUE)
return listOf(
generateFakeEngines("1", "Google"),
generateFakeEngines("2", "Bing"),
generateFakeEngines("3", "Bing"),
generateFakeEngines("4", "Amazon.com"),
generateFakeEngines("5", "DuckDuckGo"),
generateFakeEngines("6", "Qwant"),
generateFakeEngines("7", "eBay"),
generateFakeEngines("8", "Reddit"),
generateFakeEngines("9", "YouTube"),
generateFakeEngines("10", "Yandex", SearchEngine.Type.CUSTOM),
)
}
private fun generateFakeEngines(
id: String,
name: String,
type: SearchEngine.Type = SearchEngine.Type.BUNDLED,
): SearchEngine {
return SearchEngine(
id = id,
name = name,
icon = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.BLUE)
},
type = type,
isGeneral = true,
)
}

@ -0,0 +1,110 @@
/* 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.settings.search
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.MainScope
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.FragmentSearchShortcutsBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
import org.mozilla.fenix.utils.allowUndo
/**
* A [Fragment] that allows user to select what search engine shortcuts will be visible in the quick
* search menu.
*/
class SearchShortcutsFragment : Fragment(R.layout.fragment_search_shortcuts) {
private var _binding: FragmentSearchShortcutsBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentSearchShortcutsBinding.inflate(
inflater,
container,
false,
)
binding.root.setContent {
FirefoxTheme(theme = Theme.getTheme(allowPrivateTheme = false)) {
SearchEngineShortcuts(
getString(R.string.preferences_category_engines_in_search_menu),
requireComponents.core.store,
onEditEngineClicked = {
navigateToSaveEngineFragment(it)
},
onCheckboxClicked = { engine, isEnabled ->
requireContext().components.useCases.searchUseCases
.updateDisabledSearchEngineIds(
engine.id,
isEnabled,
)
},
onDeleteEngineClicked = {
deleteSearchEngine(requireContext(), it)
},
onAddEngineClicked = {
navigateToSaveEngineFragment()
},
)
}
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun navigateToSaveEngineFragment(engine: SearchEngine? = null) {
val directions = SearchShortcutsFragmentDirections
.actionSearchShortcutsFragmentToSaveSearchEngineFragment(engine?.id)
findNavController().navigate(directions)
}
private fun deleteSearchEngine(
context: Context,
engine: SearchEngine,
) {
context.components.useCases.searchUseCases.removeSearchEngine(engine)
MainScope().allowUndo(
view = context.getRootView()!!,
message = context
.getString(R.string.search_delete_search_engine_success_message, engine.name),
undoActionTitle = context.getString(R.string.snackbar_deleted_undo),
onCancel = {
context.components.useCases.searchUseCases.addSearchEngine(engine)
},
operation = {},
)
}
override fun onResume() {
super.onResume()
view?.hideKeyboard()
showToolbar(getString(R.string.preferences_manage_search_shortcuts))
}
}

@ -1773,4 +1773,9 @@ class Settings(private val appContext: Context) : PreferencesHolder {
key = appContext.getPreferenceKey(R.string.pref_key_growth_early_search),
default = false,
)
/**
* Indicates if the new Search settings UI is enabled.
*/
var enableUnifiedSearchSettingsUI: Boolean = showUnifiedSearchFeature && FeatureFlags.unifiedSearchSettings
}

@ -0,0 +1,8 @@
<?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/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="?attr/textDisabled" />
<item android:color="?attr/textOnColorPrimary"/>
</selector>

@ -0,0 +1,117 @@
<?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/. -->
<ScrollView android:id="@+id/search_engine_scrollview"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.mozilla.fenix.settings.search.AddSearchEngineFragment"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:id="@+id/search_engine_wrapper"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:contentDescription="@string/search_add_custom_engine_form_description"
android:importantForAutofill="noExcludeDescendants">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/search_add_custom_engine_name_label"
android:textColor="?attr/textSecondary"
android:textFontWeight="@integer/font_weight_light"
android:textSize="12sp"
tools:targetApi="p" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/custom_search_engine_name_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
app:hintEnabled="false"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_engine_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/accessibility_min_height"
android:hint="@string/search_add_custom_engine_name_hint_2"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/search_add_custom_engine_url_label"
android:textColor="?attr/textSecondary"
android:textFontWeight="@integer/font_weight_light"
android:textSize="12sp"
tools:targetApi="p" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/custom_search_engine_search_string_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:hintTextColor="?attr/textSecondary"
android:textColorHint="?attr/textSecondary"
app:hintTextAppearance="@style/EngineTextField"
app:hintEnabled="false"
android:paddingBottom="8dp"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_search_string"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/accessibility_min_height"
android:hint="@string/search_add_custom_engine_search_string_hint_2"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/custom_search_engines_learn_more_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:minHeight="@dimen/accessibility_min_height"
android:paddingBottom="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_add_custom_engine_search_string_example"
app:lineHeight="18sp"
android:labelFor="@id/edit_search_string"
android:textColor="@android:color/tertiary_text_dark" />
<org.mozilla.fenix.utils.LinkTextView
android:id="@+id/custom_search_engines_learn_more"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/exceptions_empty_message_learn_more_link"
android:textColor="?accent"
android:visibility="visible"
app:layout_constraintTop_toBottomOf="@id/exceptions_empty_message" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/save_button"
style="@style/PositiveButton"
android:backgroundTint="@color/button_state_list"
android:text="@string/search_custom_engine_save_button"
android:textColor="@color/text_on_color_state_list_text_color"/>
<ProgressBar
android:id="@+id/progress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:visibility="gone"/>
</LinearLayout>
</ScrollView>

@ -0,0 +1,8 @@
<?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.compose.ui.platform.ComposeView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />

@ -1272,6 +1272,35 @@
<action
android:id="@+id/action_searchEngineFragment_to_editCustomSearchEngineFragment"
app:destination="@+id/editCustomSearchEngineFragment" />
<action
android:id="@+id/action_searchEngineFragment_to_defaultEngineFragment"
app:destination="@+id/defaultEngineFragment" />
<action
android:id="@+id/action_searchEngineFragment_to_searchShortcutsFragment"
app:destination="@+id/searchShortcutsFragment" />
</fragment>
<fragment
android:id="@+id/defaultEngineFragment"
android:name="org.mozilla.fenix.settings.search.DefaultSearchEngineFragment">
<action
android:id="@+id/action_defaultEngineFragment_to_saveSearchEngineFragment"
app:destination="@+id/saveSearchEngineFragment" />
</fragment>
<fragment
android:id="@+id/searchShortcutsFragment"
android:name="org.mozilla.fenix.settings.search.SearchShortcutsFragment"
tools:layout="@layout/fragment_search_shortcuts">
<action
android:id="@+id/action_searchShortcutsFragment_to_saveSearchEngineFragment"
app:destination="@+id/saveSearchEngineFragment" />
</fragment>
<fragment
android:id="@+id/saveSearchEngineFragment"
android:name="org.mozilla.fenix.settings.search.SaveSearchEngineFragment">
<argument
android:name="searchEngineIdentifier"
app:argType="string"
app:nullable="true"/>
</fragment>
<fragment
android:id="@+id/addSearchEngineFragment"

@ -4,6 +4,7 @@
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<resources>
<!-- Font weights -->
<integer name="font_weight_light">400</integer>
<integer name="font_weight_medium">500</integer>
<integer name="font_weight_semi_bold">600</integer>
</resources>

@ -6,6 +6,8 @@
<string name="pref_key_search_settings" translatable="false">pref_key_search_settings</string>
<string name="pref_key_search_engine" translatable="false">pref_key_search_engine</string>
<string name="pref_key_add_search_engine" translatable="false">pref_key_add_Search_engine</string>
<string name="pref_key_default_search_engine" translatable="false">pref_key_default_search_engine</string>
<string name="pref_key_manage_search_shortcuts" translatable="false">pref_key_manage_search_shortcuts</string>
<string name="pref_key_passwords" translatable="false">pref_key_passwords</string>
<string name="pref_key_site_permissions" translatable="false">pref_key_site_permissions</string>
<string name="pref_key_add_private_browsing_shortcut" translatable="false">pref_key_add_private_browsing_shortcut</string>

@ -349,13 +349,13 @@
<!-- Preference category for all links about Fenix -->
<string name="preferences_category_about">About</string>
<!-- Preference category for settings related to changing the default search engine -->
<string name="preferences_category_select_default_search_engine" tools:ignore="UnusedResources">Select one</string>
<string name="preferences_category_select_default_search_engine">Select one</string>
<!-- Preference for settings related to managing search shortcuts for the quick search menu -->
<string name="preferences_manage_search_shortcuts" tools:ignore="UnusedResources">Manage search shortcuts</string>
<string name="preferences_manage_search_shortcuts">Manage search shortcuts</string>
<!-- Summary for preference for settings related to managing search shortcuts for the quick search menu -->
<string name="preferences_manage_search_shortcuts_summary" tools:ignore="UnusedResources">Edit engines visible in the search menu</string>
<string name="preferences_manage_search_shortcuts_summary">Edit engines visible in the search menu</string>
<!-- Preference category for settings related to managing search shortcuts for the quick search menu -->
<string name="preferences_category_engines_in_search_menu" tools:ignore="UnusedResources">Engines visible on the search menu</string>
<string name="preferences_category_engines_in_search_menu">Engines visible on the search menu</string>
<!-- Preference for settings related to changing the default search engine -->
<string name="preferences_default_search_engine">Default search engine</string>
<!-- Preference for settings related to Search -->
@ -1808,7 +1808,7 @@
<!-- Title of the Add search engine screen -->
<string name="search_engine_add_custom_search_engine_title">Add search engine</string>
<!-- Content description (not visible, for screen readers etc.): Title for the button that navigates to add new engine screen -->
<string name="search_engine_add_custom_search_engine_button_content_description" tools:ignore="UnusedResources">Add new search engine</string>
<string name="search_engine_add_custom_search_engine_button_content_description">Add new search engine</string>
<!-- Title of the Edit search engine screen -->
<string name="search_engine_edit_custom_search_engine_title">Edit search engine</string>
<!-- Content description (not visible, for screen readers etc.): Title for the button to add a search engine in the action bar -->
@ -1823,23 +1823,23 @@
<!-- Text for the button to create a custom search engine on the Add search engine screen -->
<string name="search_add_custom_engine_label_other">Other</string>
<!-- Label for the TextField in which user enters custom search engine name -->
<string name="search_add_custom_engine_name_label" tools:ignore="UnusedResources">Name</string>
<string name="search_add_custom_engine_name_label">Name</string>
<!-- Placeholder text shown in the Search Engine Name TextField before a user enters text -->
<string name="search_add_custom_engine_name_hint">Name</string>
<!-- Placeholder text shown in the Search Engine Name text field before a user enters text -->
<string name="search_add_custom_engine_name_hint_2" tools:ignore="UnusedResources">Search engine name</string>
<string name="search_add_custom_engine_name_hint_2">Search engine name</string>
<!-- Label for the TextField in which user enters custom search engine URL -->
<string name="search_add_custom_engine_url_label" tools:ignore="UnusedResources">Search string URL</string>
<string name="search_add_custom_engine_url_label">Search string URL</string>
<!-- Placeholder text shown in the Search String TextField before a user enters text -->
<string name="search_add_custom_engine_search_string_hint">Search string to use</string>
<!-- Placeholder text shown in the Search String TextField before a user enters text -->
<string name="search_add_custom_engine_search_string_hint_2" tools:ignore="UnusedResources">URL to use for search</string>
<string name="search_add_custom_engine_search_string_hint_2">URL to use for search</string>
<!-- Description text for the Search String TextField. The %s is part of the string -->
<string name="search_add_custom_engine_search_string_example" formatted="false">Replace query with “%s”. Example:\nhttps://www.google.com/search?q=%s</string>
<!-- Accessibility description for the form in which details about the custom search engine are entered -->
<string name="search_add_custom_engine_form_description">Custom search engine details</string>
<!-- The text for the "Save" button for saving a custom search engine -->
<string name="search_custom_engine_save_button" tools:ignore="UnusedResources">Save</string>
<string name="search_custom_engine_save_button">Save</string>
<!-- Text shown when a user leaves the name field empty -->
<string name="search_add_custom_engine_error_empty_name">Enter search engine name</string>

@ -0,0 +1,20 @@
<?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/. -->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:title="@string/preferences_category_select_default_search_engine"
android:selectable="false"
app:iconSpaceReserved="false"
android:layout="@layout/preference_category_no_icon_style">
<org.mozilla.fenix.settings.search.RadioSearchEngineListPreference
android:key="@string/pref_key_search_engine_list"
android:selectable="false"/>
<Preference
android:key="@string/pref_key_add_search_engine"
android:title="@string/search_engine_add_custom_search_engine_title"
android:layout="@layout/preference_search_add_engine"/>
</PreferenceCategory>
</PreferenceScreen>

@ -0,0 +1,76 @@
<?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/. -->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:title="Search Engines"
android:selectable="false"
app:iconSpaceReserved="false"
android:layout="@layout/preference_category_no_icon_style">
<Preference
app:iconSpaceReserved="false"
android:key="@string/pref_key_default_search_engine"
android:title="@string/preferences_default_search_engine"/>
<Preference
app:iconSpaceReserved="false"
android:key="@string/pref_key_manage_search_shortcuts"
android:title="@string/preferences_manage_search_shortcuts"
android:summary="@string/preferences_manage_search_shortcuts_summary"/>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/preferences_search_address_bar"
android:selectable="false"
app:iconSpaceReserved="false"
android:layout="@layout/preference_category_no_icon_style">
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_show_search_engine_shortcuts"
android:title="@string/preferences_show_search_engines" />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_enable_autocomplete_urls"
android:title="@string/preferences_enable_autocomplete_urls" />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_show_clipboard_suggestions"
android:title="@string/preferences_show_clipboard_suggestions" />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_search_browsing_history"
android:title='@string/preferences_search_browsing_history' />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_search_bookmarks"
android:title='@string/preferences_search_bookmarks' />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_search_synced_tabs"
android:title='@string/preferences_search_synced_tabs' />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_show_voice_search"
android:title="@string/preferences_show_voice_search" />
<SwitchPreference
app:iconSpaceReserved="false"
android:defaultValue="true"
android:key="@string/pref_key_show_search_suggestions"
android:title="@string/preferences_show_search_suggestions" />
<CheckBoxPreference
android:defaultValue="false"
android:dependency="@string/pref_key_show_search_suggestions"
android:key="@string/pref_key_show_search_suggestions_in_private"
android:layout="@layout/checkbox_left_preference"
android:title="@string/preferences_show_search_suggestions_in_private"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>

@ -769,6 +769,7 @@ class HistoryMetadataMiddlewareTest {
regionDefaultSearchEngineId = "google",
customSearchEngines = emptyList(),
hiddenSearchEngines = emptyList(),
disabledSearchEngineIds = emptyList(),
additionalAvailableSearchEngines = emptyList(),
additionalSearchEngines = emptyList(),
regionSearchEnginesOrder = listOf("google"),

Loading…
Cancel
Save