1
0
Fork 0

Adds custom search engines (#6551)

* For #5577 - Adds button to add a new search engine

* For #5577 - Adds custom engine store

* For #5577 - Creates a custom SearchEngineProvider

* For #5577 - Gives the ability to delete search engines

* For #5577 - Adds the UI to add a custom search engine

* For #5577 - Adds form to create a custom search engine

* For #5577 - Adds the ability to add a custom search engine

*  For #5577 - Adds the ability to delete custom search engines

* For #5577 - Selects the first element on the add custom search engine screen

* For #5577 - Prevents adding a search engine that already exists

* For #5577 - Styles the add search engine preference

* For #5577 - Makes the name check case-insensitive

* For #5577 - Fix bug where home screen doesnt see new search engines

* For #5577 - Moves Search URL validation to its own type

* For #5577 - Fixes linting errors

* For #5577 - Adds the ability to edit a custom search engine

* For #5577 - Allows the user to edit a serach engine even when it is the last item in the list

* For #5577 - Adds an undo snackbar when deleting a search engine

* For #5577 - Moves all of the strings to be translated

* For #5577 - Fixes bug when deleting your default search engine

* For #5577 - Puts adding search engines behind a feature flag

* For #5577 - Navigate to custom search engine SUMO article when tapping learn more

* For #5577 - Fixes nits

* For #5577 - Uses concept-fetch to validate search string

* For #5577 - Adds string resources for the cannot reach error state
master
Jeff Boek 2019-11-19 16:30:56 -08:00 committed by GitHub
parent 8abf580579
commit 607c3d4c87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1414 additions and 72 deletions

View File

@ -70,14 +70,14 @@ android {
buildConfigField "boolean", "USE_RELEASE_VERSIONING", "true"
applicationIdSuffix ".firefox"
manifestPlaceholders = [
// This release type is meant to replace Firefox (Release channel) and therefore needs to inherit
// its sharedUserId for all eternity. See:
// https://searchfox.org/mozilla-central/search?q=moz_android_shared_id&case=false&regexp=false&path=
// Shipping an app update without sharedUserId can have
// fatal consequences. For example see:
// - https://issuetracker.google.com/issues/36924841
// - https://issuetracker.google.com/issues/36905922
"sharedUserId": "org.mozilla.firefox.sharedID"
// This release type is meant to replace Firefox (Release channel) and therefore needs to inherit
// its sharedUserId for all eternity. See:
// https://searchfox.org/mozilla-central/search?q=moz_android_shared_id&case=false&regexp=false&path=
// Shipping an app update without sharedUserId can have
// fatal consequences. For example see:
// - https://issuetracker.google.com/issues/36924841
// - https://issuetracker.google.com/issues/36905922
"sharedUserId": "org.mozilla.firefox.sharedID"
]
}
}
@ -123,7 +123,7 @@ android {
sourceSets {
androidTest {
resources.srcDirs += ['src/androidTest/resources']
resources.srcDirs += ['src/androidTest/resources']
}
}
@ -544,22 +544,22 @@ if (project.hasProperty("coverage")) {
task printVariants {
doLast {
def variants = android.applicationVariants.collect {[
apks: it.variantData.outputScope.apkDatas.collect {[
abi: it.filters.find { it.filterType == 'ABI' }.identifier,
fileName: it.outputFileName,
]},
build_type: it.buildType.name,
engine: it.productFlavors.find { it.dimension == 'engine' }.name,
name: it.name,
apks: it.variantData.outputScope.apkDatas.collect {[
abi: it.filters.find { it.filterType == 'ABI' }.identifier,
fileName: it.outputFileName,
]},
build_type: it.buildType.name,
engine: it.productFlavors.find { it.dimension == 'engine' }.name,
name: it.name,
]}
println 'variants: ' + groovy.json.JsonOutput.toJson(variants)
}
}
def glean_android_components_tag = (
Versions.mozilla_android_components.endsWith('-SNAPSHOT') ?
'master' :
'v' + Versions.mozilla_android_components
Versions.mozilla_android_components.endsWith('-SNAPSHOT') ?
'master' :
'v' + Versions.mozilla_android_components
)
// Generate markdown docs for the collected metrics.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
<?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/. -->
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
<ShortName>Startpage.com</ShortName>
<Description>Startpage.com Search</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAACNAAAAjQHGZvekAAAAhklEQVQ4jWNMLf6fwMDAMIGBgYGfgTTwkYGBoYCJTM0MUD0TmMjUDDeEiQLNYDBqAAMD4807///LSjMwcHKQacD/////owveuvOM4fOXH3D+mfN3sWq+dfsZA2NEfAnYgCdPXzA8efqSdBcoaLpiuIAUMDjSAShXkQs+ggwoINOQjwwMDAUAhucl85AOIvIAAAAASUVORK5CYII=</Image>
<Url type="text/html" template="https://www.startpage.com/do/dsearch?query={searchTerms}&amp;cat=web&amp;pl=opensearch&amp;language=english"></Url>
<Url type="application/x-suggestions+json" template="https://www.startpage.com/cgi-bin/csuggest?query={searchTerms}&amp;limit=10&amp;lang=english&amp;format=json"/>
</SearchPlugin>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,8 +9,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import mozilla.components.browser.search.SearchEngineManager
import mozilla.components.browser.search.provider.AssetsSearchEngineProvider
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.test.Mockable
@ -19,15 +18,15 @@ import org.mozilla.fenix.test.Mockable
*/
@Mockable
class Search(private val context: Context) {
val provider = FenixSearchEngineProvider(context)
/**
* This component provides access to a centralized registry of search engines.
*/
val searchEngineManager by lazy {
SearchEngineManager(
coroutineContext = IO, providers = listOf(
AssetsSearchEngineProvider(LocaleSearchLocalizationProvider())
)
coroutineContext = IO,
providers = listOf(provider)
).apply {
registerForLocaleUpdates(context)
GlobalScope.launch {

View File

@ -0,0 +1,130 @@
/* 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.components.searchengine
import android.content.Context
import android.content.SharedPreferences
import mozilla.components.browser.icons.IconRequest
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.SearchEngineParser
import mozilla.components.browser.search.provider.SearchEngineList
import mozilla.components.browser.search.provider.SearchEngineProvider
import mozilla.components.support.ktx.android.content.PreferencesHolder
import mozilla.components.support.ktx.android.content.stringSetPreference
import org.mozilla.fenix.ext.components
import java.lang.Exception
/**
* SearchEngineProvider implementation to load user entered custom search engines.
*/
class CustomSearchEngineProvider : SearchEngineProvider {
override suspend fun loadSearchEngines(context: Context): SearchEngineList {
return SearchEngineList(CustomSearchEngineStore.loadCustomSearchEngines(context), null)
}
}
/**
* Object to handle storing custom search engines
*/
object CustomSearchEngineStore {
class EngineNameAlreadyExists : Exception()
/**
* Add a search engine to the store.
* @param context [Context] used for various Android interactions.
* @param engineName The name of the search engine
* @param searchQuery The templated search string for the search engine
* @throws EngineNameAlreadyExists if you try to add a search engine that already exists
*/
suspend fun addSearchEngine(context: Context, engineName: String, searchQuery: String) {
val storage = engineStorage(context)
if (storage.customSearchEngineIds.contains(engineName)) { throw EngineNameAlreadyExists() }
val icon = context.components.core.icons.loadIcon(IconRequest(searchQuery)).await()
val searchEngineXml = SearchEngineWriter.buildSearchEngineXML(engineName, searchQuery, icon.bitmap)
val engines = storage.customSearchEngineIds.toMutableSet()
engines.add(engineName)
storage.customSearchEngineIds = engines
storage[engineName] = searchEngineXml
}
/**
* Updates an existing search engine.
* To prevent duplicate search engines we want to remove the old engine before adding the new one
* @param context [Context] used for various Android interactions.
* @param oldEngineName the name of the engine you want to replace
* @param newEngineName the name of the engine you want to save
* @param searchQuery The templated search string for the search engine
*/
suspend fun updateSearchEngine(
context: Context,
oldEngineName: String,
newEngineName: String,
searchQuery: String
) {
removeSearchEngine(context, oldEngineName)
addSearchEngine(context, newEngineName, searchQuery)
}
/**
* Removes a search engine from the store
* @param context [Context] used for various Android interactions.
* @param engineId the id of the engine you want to remove
*/
fun removeSearchEngine(context: Context, engineId: String) {
val storage = engineStorage(context)
val customEngines = storage.customSearchEngineIds
storage.customSearchEngineIds = customEngines.filterNot { it == engineId }.toSet()
storage[engineId] = null
}
/**
* Checks the store to see if it contains a search engine
* @param context [Context] used for various Android interactions.
* @param engineId The name of the engine to check
*/
fun isCustomSearchEngine(context: Context, engineId: String): Boolean {
val storage = engineStorage(context)
return storage.customSearchEngineIds.contains(engineId)
}
/**
* Creates a list of [SearchEngine] from the store
* @param context [Context] used for various Android interactions.
*/
fun loadCustomSearchEngines(context: Context): List<SearchEngine> {
val storage = engineStorage(context)
val parser = SearchEngineParser()
val engines = storage.customSearchEngineIds
return engines.mapNotNull {
val engineXml = storage[it] ?: return@mapNotNull null
val engineInputStream = engineXml.byteInputStream().buffered()
parser.load(it, engineInputStream)
}
}
/**
* Creates a helper object to help interact with [SharedPreferences]
* @param context [Context] used for various Android interactions.
*/
private fun engineStorage(context: Context) = object : PreferencesHolder {
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE)
var customSearchEngineIds by stringSetPreference(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet())
operator fun get(engineId: String): String? {
return preferences.getString(engineId, null)
}
operator fun set(engineId: String, value: String?) {
preferences.edit().putString(engineId, value).apply()
}
}
private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines"
private const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines"
}

View File

@ -0,0 +1,152 @@
/* 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.components.searchengine
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.provider.AssetsSearchEngineProvider
import mozilla.components.browser.search.provider.SearchEngineList
import mozilla.components.browser.search.provider.SearchEngineProvider
import mozilla.components.browser.search.provider.filter.SearchEngineFilter
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
import org.mozilla.fenix.ext.settings
@SuppressWarnings("TooManyFunctions")
class FenixSearchEngineProvider(
private val context: Context
) : SearchEngineProvider, CoroutineScope by CoroutineScope(Job() + Dispatchers.IO) {
private val defaultEngines = async {
AssetsSearchEngineProvider(LocaleSearchLocalizationProvider()).loadSearchEngines(context)
}
private val bundledEngines = async {
AssetsSearchEngineProvider(
LocaleSearchLocalizationProvider(),
filters = listOf(object : SearchEngineFilter {
override fun filter(context: Context, searchEngine: SearchEngine): Boolean {
return BUNDLED_SEARCH_ENGINES.contains(searchEngine.identifier)
}
}),
additionalIdentifiers = BUNDLED_SEARCH_ENGINES
).loadSearchEngines(context)
}
private var customEngines = async {
CustomSearchEngineProvider().loadSearchEngines(context)
}
private var loadedSearchEngines = refreshAsync()
fun getDefaultEngine(context: Context): SearchEngine {
val engines = installedSearchEngines(context)
val selectedName = context.settings().defaultSearchEngineName
return engines.list.find { it.name == selectedName } ?: engines.list.first()
}
fun installedSearchEngines(context: Context): SearchEngineList = runBlocking {
val engineList = loadedSearchEngines.await()
val installedIdentifiers = installedSearchEngineIdentifiers(context)
engineList.copy(
list = engineList.list.filter {
installedIdentifiers.contains(it.identifier)
},
default = engineList.default?.let {
if (installedIdentifiers.contains(it.identifier)) {
it
} else {
null
}
}
)
}
fun allSearchEngineIdentifiers() = runBlocking {
loadedSearchEngines.await().list.map { it.identifier }
}
fun uninstalledSearchEngines(context: Context): SearchEngineList = runBlocking {
val engineList = loadedSearchEngines.await()
val installedIdentifiers = installedSearchEngineIdentifiers(context)
engineList.copy(list = engineList.list.filterNot { installedIdentifiers.contains(it.identifier) })
}
override suspend fun loadSearchEngines(context: Context): SearchEngineList {
return installedSearchEngines(context)
}
fun installSearchEngine(context: Context, searchEngine: SearchEngine) = runBlocking {
val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
installedIdentifiers.add(searchEngine.identifier)
prefs(context).edit().putStringSet(INSTALLED_ENGINES_KEY, installedIdentifiers).apply()
}
fun uninstallSearchEngine(context: Context, searchEngine: SearchEngine) = runBlocking {
val isCustom = CustomSearchEngineStore.isCustomSearchEngine(context, searchEngine.identifier)
if (isCustom) {
CustomSearchEngineStore.removeSearchEngine(context, searchEngine.identifier)
} else {
val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
installedIdentifiers.remove(searchEngine.identifier)
prefs(context).edit().putStringSet(INSTALLED_ENGINES_KEY, installedIdentifiers).apply()
}
reload()
}
fun reload() {
launch {
customEngines = async { CustomSearchEngineProvider().loadSearchEngines(context) }
loadedSearchEngines = refreshAsync()
}
}
private fun refreshAsync() = async {
val engineList = defaultEngines.await()
val bundledList = bundledEngines.await().list
val customList = customEngines.await().list
engineList.copy(list = engineList.list + bundledList + customList)
}
private fun prefs(context: Context) = context.getSharedPreferences(
PREF_FILE,
Context.MODE_PRIVATE
)
private suspend fun installedSearchEngineIdentifiers(context: Context): Set<String> {
val prefs = prefs(context)
val identifiers = if (!prefs.contains(INSTALLED_ENGINES_KEY)) {
val defaultSet = defaultEngines.await()
.list
.map { it.identifier }
.toSet()
prefs.edit().putStringSet(INSTALLED_ENGINES_KEY, defaultSet).apply()
defaultSet
} else {
prefs(context).getStringSet(INSTALLED_ENGINES_KEY, setOf()) ?: setOf()
}
val customEngineIdentifiers = customEngines.await().list.map { it.identifier }.toSet()
return identifiers + customEngineIdentifiers
}
companion object {
private val BUNDLED_SEARCH_ENGINES = listOf("ecosia", "reddit", "startpage", "yahoo", "youtube")
private const val PREF_FILE = "fenix-search-engine-provider"
private const val INSTALLED_ENGINES_KEY = "fenix-installed-search-engines"
}
}

View File

@ -0,0 +1,84 @@
/* 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.components.searchengine
import android.graphics.Bitmap
import android.util.Log
import org.w3c.dom.Document
import java.io.StringWriter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.parsers.ParserConfigurationException
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerConfigurationException
import javax.xml.transform.TransformerException
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
import android.util.Base64
import java.io.ByteArrayOutputStream
private const val BITMAP_COMPRESS_QUALITY = 100
private fun Bitmap.toBase64(): String {
val stream = ByteArrayOutputStream()
compress(Bitmap.CompressFormat.PNG, BITMAP_COMPRESS_QUALITY, stream)
val encodedImage = Base64.encodeToString(stream.toByteArray(), Base64.DEFAULT)
return "data:image/png;base64,$encodedImage"
}
class SearchEngineWriter {
companion object {
private const val LOG_TAG = "SearchEngineWriter"
fun buildSearchEngineXML(engineName: String, searchQuery: String, iconBitmap: Bitmap): String? {
try {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
val rootElement = document!!.createElement("OpenSearchDescription")
rootElement.setAttribute("xmlns", "http://a9.com/-/spec/opensearch/1.1/")
document.appendChild(rootElement)
val shortNameElement = document.createElement("ShortName")
shortNameElement.textContent = engineName
rootElement.appendChild(shortNameElement)
val imageElement = document.createElement("Image")
imageElement.setAttribute("width", "16")
imageElement.setAttribute("height", "16")
imageElement.textContent = iconBitmap.toBase64()
rootElement.appendChild(imageElement)
val descriptionElement = document.createElement("Description")
descriptionElement.textContent = engineName
rootElement.appendChild(descriptionElement)
val urlElement = document.createElement("Url")
urlElement.setAttribute("type", "text/html")
val templateSearchString = searchQuery.replace("%s", "{searchTerms}")
urlElement.setAttribute("template", templateSearchString)
rootElement.appendChild(urlElement)
return xmlToString(document)
} catch (e: ParserConfigurationException) {
Log.e(LOG_TAG, "Couldn't create new Document for building search engine XML", e)
return null
}
}
private fun xmlToString(doc: Document): String? {
val writer = StringWriter()
try {
val tf = TransformerFactory.newInstance().newTransformer()
tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8")
tf.transform(DOMSource(doc), StreamResult(writer))
} catch (e: TransformerConfigurationException) {
return null
} catch (e: TransformerException) {
return null
}
return writer.toString()
}
}
}

View File

@ -233,10 +233,7 @@ class HomeFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val iconSize = resources.getDimension(R.dimen.preference_icon_drawable_size).toInt()
val searchEngine = requireComponents.search.searchEngineManager.getDefaultSearchEngineAsync(
requireContext(),
requireContext().settings().defaultSearchEngineName
)
val searchEngine = requireComponents.search.provider.getDefaultEngine(requireContext())
val searchIcon = BitmapDrawable(resources, searchEngine.icon)
searchIcon.setBounds(0, 0, iconSize, iconSize)

View File

@ -78,7 +78,7 @@ class SearchFragment : Fragment(), BackHandler {
val view = inflater.inflate(R.layout.fragment_search, container, false)
val url = session?.url.orEmpty()
val currentSearchEngine = SearchEngineSource.Default(
requireComponents.search.searchEngineManager.getDefaultSearchEngine(requireContext())
requireComponents.search.provider.getDefaultEngine(requireContext())
)
searchStore = StoreProvider.get(this) {
@ -204,10 +204,7 @@ class SearchFragment : Fragment(), BackHandler {
// The user has the option to go to 'Shortcuts' -> 'Search engine settings' to modify the default search engine.
// When returning from that settings screen we need to update it to account for any changes.
val currentDefaultEngine =
requireComponents.search.searchEngineManager.getDefaultSearchEngine(
requireContext(),
requireContext().settings().defaultSearchEngineName
)
requireComponents.search.provider.getDefaultEngine(requireContext())
if (searchStore.state.defaultEngineSource.searchEngine != currentDefaultEngine) {
searchStore.dispatch(

View File

@ -168,7 +168,7 @@ class AwesomeBarView(
shortcutsEnginePickerProvider =
ShortcutsSuggestionProvider(
components.search.searchEngineManager,
components.search.provider,
this,
interactor::onSearchShortcutEngineSelected,
interactor::onClickSearchEngineSettings
@ -329,10 +329,7 @@ class AwesomeBarView(
searchSuggestionProviderMap.put(
engine, SearchSuggestionProvider(
components.search.searchEngineManager.getDefaultSearchEngine(
this,
engine.name
),
components.search.provider.getDefaultEngine(this),
shortcutSearchUseCase,
components.core.client,
limit = 3,

View File

@ -7,16 +7,16 @@ package org.mozilla.fenix.search.awesomebar
import android.content.Context
import androidx.core.graphics.drawable.toBitmap
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.SearchEngineManager
import mozilla.components.concept.awesomebar.AwesomeBar
import org.mozilla.fenix.R
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import java.util.UUID
/**
* A [AwesomeBar.SuggestionProvider] implementation that provides search engine suggestions.
*/
class ShortcutsSuggestionProvider(
private val searchEngineManager: SearchEngineManager,
private val searchEngineProvider: FenixSearchEngineProvider,
private val context: Context,
private val selectShortcutEngine: (engine: SearchEngine) -> Unit,
private val selectShortcutEngineSettings: () -> Unit
@ -33,7 +33,7 @@ class ShortcutsSuggestionProvider(
override suspend fun onInputChanged(text: String): List<AwesomeBar.Suggestion> {
val suggestions = mutableListOf<AwesomeBar.Suggestion>()
searchEngineManager.getSearchEngines(context).forEach {
searchEngineProvider.installedSearchEngines(context).list.forEach {
suggestions.add(
AwesomeBar.Suggestion(
provider = this,

View File

@ -30,7 +30,8 @@ object SupportUtils {
TRACKING_PROTECTION("tracking-protection-firefox-preview"),
WHATS_NEW("whats-new-firefox-preview"),
SEND_TABS("send-tab-preview"),
SET_AS_DEFAULT_BROWSER("set-firefox-preview-default")
SET_AS_DEFAULT_BROWSER("set-firefox-preview-default"),
CUSTOM_SEARCH_ENGINES("custom-search-engines")
}
/**

View File

@ -0,0 +1,289 @@
/* 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.res.Resources
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.CompoundButton
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.custom_search_engine.*
import kotlinx.android.synthetic.main.fragment_add_search_engine.*
import kotlinx.android.synthetic.main.search_engine_radio_button.view.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.settings.SupportUtils
import java.util.Locale
@SuppressWarnings("LargeClass", "TooManyFunctions")
class AddSearchEngineFragment : Fragment(), CompoundButton.OnCheckedChangeListener {
private var availableEngines: List<SearchEngine> = listOf()
private var selectedIndex: Int = -1
private val engineViews = mutableListOf<View>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
availableEngines = runBlocking {
requireContext()
.components
.search
.provider
.uninstalledSearchEngines(requireContext())
.list
}
selectedIndex = if (availableEngines.isEmpty()) CUSTOM_INDEX else FIRST_INDEX
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_add_search_engine, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val layoutInflater = LayoutInflater.from(context)
val layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine ->
val engineId = engine.identifier
val engineItem = makeButtonFromSearchEngine(
engine = engine,
layoutInflater = layoutInflater,
res = requireContext().resources
)
engineItem.id = index
engineItem.tag = engineId
engineItem.radio_button.isChecked = selectedIndex == index
engineViews.add(engineItem)
search_engine_group.addView(engineItem, layoutParams)
}
availableEngines.forEachIndexed(setupSearchEngineItem)
val engineItem = makeCustomButton(layoutInflater)
engineItem.id = CUSTOM_INDEX
engineItem.radio_button.isChecked = selectedIndex == CUSTOM_INDEX
engineViews.add(engineItem)
search_engine_group.addView(engineItem, layoutParams)
toggleCustomForm(selectedIndex == CUSTOM_INDEX)
custom_search_engines_learn_more.increaseTapArea(DPS_TO_INCREASE)
custom_search_engines_learn_more.setOnClickListener {
requireContext().let { context ->
val intent = SupportUtils.createCustomTabIntent(
context,
SupportUtils.getSumoURLForTopic(
context,
SupportUtils.SumoTopic.CUSTOM_SEARCH_ENGINES
)
)
startActivity(intent)
}
}
}
override fun onResume() {
super.onResume()
(activity as AppCompatActivity).title = getString(R.string.search_engine_add_custom_search_engine_title)
(activity as AppCompatActivity).supportActionBar?.show()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.add_custom_searchengine_menu, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_search_engine -> {
when (selectedIndex) {
CUSTOM_INDEX -> createCustomEngine()
else -> {
val engine = availableEngines[selectedIndex]
installEngine(engine)
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun createCustomEngine() {
custom_search_engine_name_field.error = ""
custom_search_engine_search_string_field.error = ""
val name = edit_engine_name.text?.toString() ?: ""
val searchString = edit_search_string.text?.toString() ?: ""
var hasError = false
if (name.isEmpty()) {
custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_empty_name)
hasError = true
}
val existingIdentifiers = requireComponents
.search
.provider
.allSearchEngineIdentifiers()
.map { it.toLowerCase(Locale.ROOT) }
if (existingIdentifiers.contains(name.toLowerCase(Locale.ROOT))) {
custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_existing_name, name)
hasError = true
}
if (searchString.isEmpty()) {
custom_search_engine_search_string_field
.error = resources.getString(R.string.search_add_custom_engine_error_empty_search_string)
hasError = true
}
if (!searchString.contains("%s")) {
custom_search_engine_search_string_field
.error = resources.getString(R.string.search_add_custom_engine_error_missing_template)
hasError = true
}
if (hasError) { return }
viewLifecycleOwner.lifecycleScope.launch(Main) {
val result = withContext(IO) {
SearchStringValidator.isSearchStringValid(
requireComponents.core.client,
searchString
)
}
when (result) {
SearchStringValidator.Result.CannotReach -> {
custom_search_engine_search_string_field.error = resources
.getString(R.string.search_add_custom_engine_error_cannot_reach)
}
SearchStringValidator.Result.Success -> {
CustomSearchEngineStore.addSearchEngine(
context = requireContext(),
engineName = name,
searchQuery = searchString
)
requireComponents.search.provider.reload()
val successMessage = resources
.getString(R.string.search_add_custom_engine_success_message, name)
view?.also {
FenixSnackbar.make(it, FenixSnackbar.LENGTH_SHORT)
.setText(successMessage)
.show()
}
findNavController().popBackStack()
}
}
}
}
private fun installEngine(engine: SearchEngine) {
viewLifecycleOwner.lifecycleScope.launch(Main) {
withContext(IO) {
requireContext().components.search.provider.installSearchEngine(
requireContext(),
engine
)
}
findNavController().popBackStack()
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
engineViews.forEach {
when (it.radio_button == buttonView) {
true -> {
selectedIndex = it.id
}
false -> {
it.radio_button.setOnCheckedChangeListener(null)
it.radio_button.isChecked = false
it.radio_button.setOnCheckedChangeListener(this)
}
}
}
toggleCustomForm(selectedIndex == -1)
}
private fun makeCustomButton(layoutInflater: LayoutInflater): View {
val wrapper = layoutInflater
.inflate(R.layout.custom_search_engine_radio_button, null) as ConstraintLayout
wrapper.setOnClickListener { wrapper.radio_button.isChecked = true }
wrapper.radio_button.setOnCheckedChangeListener(this)
return wrapper
}
private fun toggleCustomForm(isEnabled: Boolean) {
custom_search_engine_form.alpha = if (isEnabled) ENABLED_ALPHA else DISABLED_ALPHA
edit_search_string.isEnabled = isEnabled
edit_engine_name.isEnabled = isEnabled
custom_search_engines_learn_more.isEnabled = isEnabled
}
private fun makeButtonFromSearchEngine(
engine: SearchEngine,
layoutInflater: LayoutInflater,
res: Resources
): View {
val wrapper = layoutInflater
.inflate(R.layout.search_engine_radio_button, null) as ConstraintLayout
wrapper.setOnClickListener { wrapper.radio_button.isChecked = true }
wrapper.radio_button.setOnCheckedChangeListener(this)
wrapper.engine_text.text = engine.name
val iconSize = res.getDimension(R.dimen.preference_icon_drawable_size).toInt()
val engineIcon = BitmapDrawable(res, engine.icon)
engineIcon.setBounds(0, 0, iconSize, iconSize)
wrapper.engine_icon.setImageDrawable(engineIcon)
wrapper.overflow_menu.visibility = View.GONE
return wrapper
}
companion object {
private const val ENABLED_ALPHA = 1.0f
private const val DISABLED_ALPHA = 0.2f
private const val CUSTOM_INDEX = -1
private const val FIRST_INDEX = 0
private const val DPS_TO_INCREASE = 20
}
}

View File

@ -0,0 +1,170 @@
/* 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.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.custom_search_engine.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.settings.SupportUtils
import java.util.Locale
class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine) {
private val engineIdentifier: String by lazy {
navArgs<EditCustomSearchEngineFragmentArgs>().value.searchEngineIdentifier
}
private lateinit var searchEngine: SearchEngine
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
searchEngine = CustomSearchEngineStore.loadCustomSearchEngines(requireContext()).first {
it.identifier == engineIdentifier
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
edit_engine_name.setText(searchEngine.name)
val decodedUrl = Uri.decode(searchEngine.buildSearchUrl("%s"))
edit_search_string.setText(decodedUrl)
custom_search_engines_learn_more.increaseTapArea(DPS_TO_INCREASE)
custom_search_engines_learn_more.setOnClickListener {
requireContext().let { context ->
val intent = SupportUtils.createCustomTabIntent(
context,
SupportUtils.getSumoURLForTopic(
context,
SupportUtils.SumoTopic.CUSTOM_SEARCH_ENGINES
)
)
startActivity(intent)
}
}
}
override fun onResume() {
super.onResume()
(activity as AppCompatActivity).title = getString(R.string.search_engine_edit_custom_search_engine_title)
(activity as AppCompatActivity).supportActionBar?.show()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.edit_custom_searchengine_menu, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.save_button -> {
saveCustomEngine()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun saveCustomEngine() {
custom_search_engine_name_field.error = ""
custom_search_engine_search_string_field.error = ""
val name = edit_engine_name.text?.toString() ?: ""
val searchString = edit_search_string.text?.toString() ?: ""
var hasError = false
if (name.isEmpty()) {
custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_empty_name)
hasError = true
}
val existingIdentifiers = requireComponents
.search
.provider
.allSearchEngineIdentifiers()
.map { it.toLowerCase(Locale.ROOT) }
val nameHasChanged = name != engineIdentifier
if (existingIdentifiers.contains(name.toLowerCase(Locale.ROOT)) && nameHasChanged) {
custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_existing_name, name)
hasError = true
}
if (searchString.isEmpty()) {
custom_search_engine_search_string_field
.error = resources.getString(R.string.search_add_custom_engine_error_empty_search_string)
hasError = true
}
if (!searchString.contains("%s")) {
custom_search_engine_name_field
.error = resources.getString(R.string.search_add_custom_engine_error_missing_template)
hasError = true
}
if (hasError) { return }
viewLifecycleOwner.lifecycleScope.launch(Main) {
val result = withContext(IO) {
SearchStringValidator.isSearchStringValid(
requireComponents.core.client,
searchString
)
}
when (result) {
SearchStringValidator.Result.CannotReach -> {
custom_search_engine_search_string_field.error = resources
.getString(R.string.search_add_custom_engine_error_cannot_reach)
}
SearchStringValidator.Result.Success -> {
CustomSearchEngineStore.updateSearchEngine(
context = requireContext(),
oldEngineName = engineIdentifier,
newEngineName = name,
searchQuery = searchString
)
requireComponents.search.provider.reload()
val successMessage = resources
.getString(R.string.search_edit_custom_engine_success_message, name)
view?.also {
FenixSnackbar.make(it, FenixSnackbar.LENGTH_SHORT)
.setText(successMessage)
.show()
}
findNavController().popBackStack()
}
}
}
}
companion object {
private const val DPS_TO_INCREASE = 20
}
}

View File

@ -6,6 +6,8 @@ package org.mozilla.fenix.settings.search
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import org.mozilla.fenix.R
@ -49,10 +51,26 @@ class SearchEngineFragment : PreferenceFragmentCompat() {
isChecked = context.settings().shouldShowClipboardSuggestions
}
val searchEngineListPreference =
findPreference<SearchEngineListPreference>(getPreferenceKey(R.string.pref_key_search_engine_list))
searchEngineListPreference?.reload(requireContext())
searchSuggestionsPreference?.onPreferenceChangeListener = SharedPreferenceUpdater()
showSearchShortcuts?.onPreferenceChangeListener = SharedPreferenceUpdater()
showHistorySuggestions?.onPreferenceChangeListener = SharedPreferenceUpdater()
showBookmarkSuggestions?.onPreferenceChangeListener = SharedPreferenceUpdater()
showClipboardSuggestions?.onPreferenceChangeListener = SharedPreferenceUpdater()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
getPreferenceKey(R.string.pref_key_add_search_engine) -> {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToAddSearchEngineFragment()
findNavController().navigate(directions)
}
}
return super.onPreferenceTreeClick(preference)
}
}

View File

@ -14,13 +14,20 @@ import android.view.ViewGroup
import android.widget.CompoundButton
import android.widget.RadioGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.navigation.Navigation
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import kotlinx.android.synthetic.main.search_engine_radio_button.view.*
import kotlinx.coroutines.MainScope
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.provider.SearchEngineList
import org.mozilla.fenix.R
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.allowUndo
abstract class SearchEngineListPreference @JvmOverloads constructor(
context: Context,
@ -28,7 +35,7 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
defStyleAttr: Int = android.R.attr.preferenceStyle
) : Preference(context, attrs, defStyleAttr), CompoundButton.OnCheckedChangeListener {
protected var searchEngines: List<SearchEngine> = emptyList()
protected lateinit var searchEngineList: SearchEngineList
protected var searchEngineGroup: RadioGroup? = null
protected abstract val itemResId: Int
@ -40,11 +47,11 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
override fun onBindViewHolder(holder: PreferenceViewHolder?) {
super.onBindViewHolder(holder)
searchEngineGroup = holder!!.itemView.findViewById(R.id.search_engine_group)
val context = searchEngineGroup!!.context
searchEngines = context.components.search.searchEngineManager.getSearchEngines(context)
.sortedBy { it.name }
reload(searchEngineGroup!!.context)
}
fun reload(context: Context) {
searchEngineList = context.components.search.provider.installedSearchEngines(context)
refreshSearchEngineViews(context)
}
@ -59,18 +66,10 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
return
}
// To get the default search engine we have to pass in a name that doesn't exist
// https://github.com/mozilla-mobile/android-components/issues/3344
val defaultSearchEngine = context.components.search.searchEngineManager.getDefaultSearchEngine(
context,
THIS_IS_A_HACK_FIX_ME
)
val selectedSearchEngine =
context.components.search.searchEngineManager.getDefaultSearchEngine(
context,
context.settings().defaultSearchEngineName
).identifier
val defaultEngine = context.components.search.provider.getDefaultEngine(context).identifier
val selectedEngine = (searchEngineList.list.find {
it.identifier == defaultEngine
} ?: searchEngineList.list.first()).identifier
searchEngineGroup!!.removeAllViews()
@ -82,31 +81,57 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine ->
val engineId = engine.identifier
val engineItem = makeButtonFromSearchEngine(engine, layoutInflater, context.resources)
engineItem.id = index
val engineItem = makeButtonFromSearchEngine(
engine = engine,
layoutInflater = layoutInflater,
res = context.resources,
allowDeletion = searchEngineList.list.size > 1
)
engineItem.id = index + (searchEngineList.default?.let { 1 } ?: 0)
engineItem.tag = engineId
if (engineId == selectedSearchEngine) {
if (engineId == selectedEngine) {
updateDefaultItem(engineItem.radio_button)
}
searchEngineGroup!!.addView(engineItem, layoutParams)
}
setupSearchEngineItem(0, defaultSearchEngine)
searchEngineList.default?.apply {
setupSearchEngineItem(0, this)
}
searchEngines
.filter { it.identifier != defaultSearchEngine.identifier }
searchEngineList.list
.filter { it.identifier != searchEngineList.default?.identifier }
.sortedBy { it.name }
.forEachIndexed(setupSearchEngineItem)
}
private fun makeButtonFromSearchEngine(
engine: SearchEngine,
layoutInflater: LayoutInflater,
res: Resources
res: Resources,
allowDeletion: Boolean
): View {
val isCustomSearchEngine = CustomSearchEngineStore.isCustomSearchEngine(context, engine.identifier)
val wrapper = layoutInflater.inflate(itemResId, null) as ConstraintLayout
wrapper.setOnClickListener { wrapper.radio_button.isChecked = true }
wrapper.radio_button.setOnCheckedChangeListener(this)
wrapper.engine_text.text = engine.name
wrapper.overflow_menu.isVisible = allowDeletion || isCustomSearchEngine
wrapper.overflow_menu.setOnClickListener {
SearchEngineMenu(
context = context,
allowDeletion = allowDeletion,
isCustomSearchEngine = isCustomSearchEngine,
onItemTapped = {
when (it) {
is SearchEngineMenu.Item.Edit -> editCustomSearchEngine(engine)
is SearchEngineMenu.Item.Delete -> deleteSearchEngine(context, engine)
}
}
).menuBuilder.build(context).show(wrapper.overflow_menu)
}
val iconSize = res.getDimension(R.dimen.preference_icon_drawable_size).toInt()
val engineIcon = BitmapDrawable(res, engine.icon)
engineIcon.setBounds(0, 0, iconSize, iconSize)
@ -115,7 +140,7 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
searchEngines.forEach { engine ->
searchEngineList.list.forEach { engine ->
val wrapper: ConstraintLayout = searchEngineGroup?.findViewWithTag(engine.identifier) ?: return
when (wrapper.radio_button == buttonView) {
@ -129,7 +154,55 @@ abstract class SearchEngineListPreference @JvmOverloads constructor(
}
}
companion object {
private const val THIS_IS_A_HACK_FIX_ME = "."
private fun editCustomSearchEngine(engine: SearchEngine) {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.identifier)
Navigation.findNavController(searchEngineGroup!!).navigate(directions)
}
private fun deleteSearchEngine(context: Context, engine: SearchEngine) {
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 = {
val defaultEngine = context.components.search.provider.getDefaultEngine(context)
searchEngineList = searchEngineList.copy(
list = searchEngineList.list + engine,
default = defaultEngine
)
refreshSearchEngineViews(context)
},
operation = {
val defaultEngine = context.components.search.provider.getDefaultEngine(context)
context.components.search.provider.uninstallSearchEngine(context, engine)
if (engine == defaultEngine) {
context.settings().defaultSearchEngineName = context
.components
.search
.provider
.getDefaultEngine(context)
.name
}
refreshSearchEngineViews(context)
}
)
searchEngineList = searchEngineList.copy(
list = searchEngineList.list.filter {
it.identifier != engine.identifier
},
default = if (searchEngineList.default?.identifier == engine.identifier) {
null
} else {
searchEngineList.default
}
)
refreshSearchEngineViews(context)
}
}

View File

@ -0,0 +1,52 @@
/* 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 mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import org.mozilla.fenix.R
import org.mozilla.fenix.theme.ThemeManager
class SearchEngineMenu(
private val context: Context,
private val allowDeletion: Boolean,
private val isCustomSearchEngine: Boolean,
private val onItemTapped: (Item) -> Unit = {}
) {
sealed class Item {
object Delete : Item()
object Edit : Item()
}
val menuBuilder by lazy { BrowserMenuBuilder(menuItems) }
private val menuItems by lazy {
val items = mutableListOf<SimpleBrowserMenuItem>()
if (isCustomSearchEngine) {
items.add(
SimpleBrowserMenuItem(
label = context.getString(R.string.search_engine_edit)
) {
onItemTapped.invoke(Item.Edit)
}
)
}
if (allowDeletion) {
items.add(
SimpleBrowserMenuItem(
context.getString(R.string.search_engine_delete),
textColorResource = ThemeManager.resolveAttribute(R.attr.destructive, context)
) {
onItemTapped.invoke(Item.Delete)
}
)
}
items
}
}

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.settings.search
import android.net.Uri
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.isSuccess
import mozilla.components.support.ktx.kotlin.toNormalizedUrl
object SearchStringValidator {
enum class Result { Success, CannotReach }
fun isSearchStringValid(client: Client, searchString: String): Result {
val request = createRequest(searchString)
val response = client.fetch(request)
return if (response.isSuccess) Result.Success else Result.CannotReach
}
private fun createRequest(searchString: String): Request {
// we should share the code to substitute and normalize the search string (see SearchEngine.buildSearchUrl).
val encodedTestQuery = Uri.encode("testSearchEngineValidation")
val normalizedHttpsSearchUrlStr = searchString.toNormalizedUrl()
val searchUrl = normalizedHttpsSearchUrlStr.replace("%s".toRegex(), encodedTestQuery)
return Request(searchUrl)
}
}

View File

@ -0,0 +1,75 @@
<?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/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/custom_search_engine_form"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:importantForAutofill="noExcludeDescendants">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/custom_search_engine_name_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:hintTextColor="?secondaryText"
android:textColorHint="?secondaryText"
app:hintTextAppearance="@style/EngineTextField"
android:paddingBottom="8dp"
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:hint="@string/search_add_custom_engine_name_hint"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<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="?secondaryText"
android:textColorHint="?secondaryText"
app:hintTextAppearance="@style/EngineTextField"
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:hint="@string/search_add_custom_engine_search_string_hint"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_add_custom_engine_search_string_example"
android:lineHeight="18sp"
android:textColor="@android:color/tertiary_text_dark" />
<TextView
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" />
<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>

View File

@ -0,0 +1,35 @@
<?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:app="http://schemas.android.com/apk/res-auto"
android:layout_height="@dimen/search_engine_radio_button_height"
android:layout_width="match_parent"
android:background="?android:selectableItemBackground"
android:clickable="true"
android:focusable="true">
<RadioButton
android:id="@+id/radio_button"
android:layout_width="@dimen/search_engine_radio_button_height"
android:layout_height="@dimen/search_engine_radio_button_height"
android:importantForAccessibility="no"
android:textAlignment="textStart"
android:textAppearance="?android:attr/textAppearanceListItem"
android:layout_marginStart="@dimen/radio_button_padding_horizontal"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/engine_text"
android:textColor="?primaryText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/radio_button_padding_horizontal"
android:text="@string/search_add_custom_engine_label"
app:layout_constraintStart_toEndOf="@id/radio_button"
app:layout_constraintTop_toTopOf="@id/radio_button"
app:layout_constraintBottom_toBottomOf="@id/radio_button"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,24 @@
<?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">
<LinearLayout
android:id="@+id/search_engine_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioGroup
android:id="@+id/search_engine_group"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<include layout="@layout/custom_search_engine" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,11 @@
<?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
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/custom_search_engine" />
</ScrollView>

View File

@ -0,0 +1,37 @@
<?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:app="http://schemas.android.com/apk/res-auto"
android:layout_height="@dimen/search_engine_radio_button_height"
android:layout_width="match_parent"
android:background="?android:selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/add_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:textAlignment="textStart"
android:layout_marginStart="20dp"
android:src="@drawable/ic_new"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/add_engine_text"
android:textColor="?primaryText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="@dimen/radio_button_padding_horizontal"
android:text="@string/search_engine_add_custom_search_engine_title"
app:layout_constraintStart_toEndOf="@id/add_icon"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public
<?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/. -->
@ -39,4 +40,14 @@
app:layout_constraintStart_toEndOf="@id/engine_icon"
app:layout_constraintTop_toTopOf="@id/engine_icon"
app:layout_constraintEnd_toEndOf="parent" />
<ImageButton
android:id="@+id/overflow_menu"
android:layout_width="@dimen/glyph_button_width"
android:layout_height="@dimen/glyph_button_height"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/content_description_menu"
android:src="@drawable/ic_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/add_search_engine"
android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText"
android:title="@string/search_engine_add_button_content_description"
app:showAsAction="always" />
</menu>

View File

@ -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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/save_button"
android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText"
android:title="@string/search_engine_add_custom_search_engine_edit_button_content_description"
app:showAsAction="always" />
</menu>

View File

@ -428,7 +428,14 @@
<fragment
android:id="@+id/searchEngineFragment"
android:name="org.mozilla.fenix.settings.search.SearchEngineFragment"
android:label="@string/preferences_search" />
android:label="@string/preferences_search">
<action
android:id="@+id/action_searchEngineFragment_to_addSearchEngineFragment"
app:destination="@+id/addSearchEngineFragment" />
<action
android:id="@+id/action_searchEngineFragment_to_editCustomSearchEngineFragment"
app:destination="@+id/editCustomSearchEngineFragment" />
</fragment>
<fragment
android:id="@+id/turnOnSyncFragment"
@ -659,4 +666,16 @@
android:name="savedLoginItem"
app:argType="org.mozilla.fenix.logins.SavedLoginsItem" />
</fragment>
<fragment
android:id="@+id/addSearchEngineFragment"
android:name="org.mozilla.fenix.settings.search.AddSearchEngineFragment"
android:label="AddSearchEngineFragment" />
<fragment
android:id="@+id/editCustomSearchEngineFragment"
android:name="org.mozilla.fenix.settings.search.EditCustomSearchEngineFragment"
android:label="EditCustomSearchEngineFragment">
<argument
android:name="searchEngineIdentifier"
app:argType="string" />
</fragment>
</navigation>

View File

@ -5,6 +5,7 @@
<string name="pref_key_make_default_browser" translatable="false">pref_key_make_default_browser</string>
<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_passwords" translatable="false">pref_key_passwords</string>
<string name="pref_key_credit_cards_addresses" translatable="false">pref_key_credit_cards_addresses</string>
<string name="pref_key_site_permissions" translatable="false">pref_key_site_permissions</string>
@ -67,6 +68,7 @@
<string name="pref_key_search_widget_installed" translatable="false">pref_key_search_widget_installed</string>
<!-- Search Settings -->
<string name="pref_key_search_engine_list" translatable="false">pref_key_search_engine_list</string>
<string name="pref_key_show_search_shortcuts" translatable="false">pref_key_show_search_shortcuts</string>
<string name="pref_key_show_search_suggestions" translatable="false">pref_key_show_search_suggestions</string>
<string name="pref_key_show_clipboard_suggestions" translatable="false">pref_key_show_clipboard_suggestions</string>

View File

@ -25,7 +25,6 @@
<!--suppress CheckTagEmptyBody This is a default value for places where we don't want a string set-->
<string name="empty_string" translatable="false"></string>
<!-- Title for Accessibility Force Enable Zoom Preference -->
<string name="preference_accessibility_force_enable_zoom" translatable="false">Zoom on all websites</string>
<!-- Summary for Accessibility Force Enable Zoom Preference -->

View File

@ -1014,4 +1014,45 @@
<string name="logins_warning_dialog_set_up_now">Set up now</string>
<!-- Title of PIN verification dialog to direct users to re-enter their device credentials to access their logins -->
<string name="logins_biometric_prompt_message_pin">Unlock your device</string>
<!-- Title of the Add search engine screen -->
<string name="search_engine_add_custom_search_engine_title">Add 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 -->
<string name="search_engine_add_button_content_description">Add</string>
<!-- Content description (not visible, for screen readers etc.): Title for the button to save a search engine in the action bar -->
<string name="search_engine_add_custom_search_engine_edit_button_content_description">Save</string>
<!-- Text for the menu button to edit a search engine -->
<string name="search_engine_edit">Edit</string>
<!-- Text for the menu button to delete a search engine -->
<string name="search_engine_delete">Delete</string>
<!-- Text for the button to create a custom search engine on the Add search engine screen -->
<string name="search_add_custom_engine_label">Custom</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 String TextField before a user enters text -->
<string name="search_add_custom_engine_search_string_hint">Search string to use</string>
<!-- Description text for the Search String TextField. The %s is part of the string -->
<string name="search_add_custom_engine_search_string_example">Replace query with “%s”. Example:\nhttps://www.google.com/search?q=%s</string>
<!-- Text for the button to learn more about adding a custom search engine -->
<string name="search_add_custom_engine_learn_more_label">Learn More</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>
<!-- Text shown when a user tries to add a search engine that already exists -->
<string name="search_add_custom_engine_error_existing_name">Search engine with name “%s” already exists.</string>
<!-- Text shown when a user leaves the search string field empty -->
<string name="search_add_custom_engine_error_empty_search_string">Enter a search string</string>
<!-- Text shown when a user leaves out the required template string -->
<string name="search_add_custom_engine_error_missing_template">Check that search string matches Example format</string>
<!-- Text shown when we aren't able to validate the custom search query. The first parameter is the url of the custom search engine -->
<string name="search_add_custom_engine_error_cannot_reach">Error connecting to “%s”</string>
<!-- Text shown when a user creates a new search engine -->
<string name="search_add_custom_engine_success_message">Created %s</string>
<!-- Text shown when a user successfully edits a custom search engine -->
<string name="search_edit_custom_engine_success_message">Saved %s</string>
<!-- Text shown when a user successfully deletes a custom search engine -->
<string name="search_delete_search_engine_success_message">Deleted %s</string>
</resources>

View File

@ -354,4 +354,8 @@
</style>
<style name="ShareDialogStyle" parent="DialogStyleBase"/>
<style name="EngineTextField" parent="TextAppearance.AppCompat">
<item name="android:textSize">14sp</item>
</style>
</resources>

View File

@ -10,7 +10,12 @@
android:selectable="false"
app:iconSpaceReserved="false">
<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"/>
<SwitchPreference
android:defaultValue="true"
android:key="@string/pref_key_show_search_suggestions"