diff --git a/app/build.gradle b/app/build.gradle
index a1dc3caa5..f1b83d1e2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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®exp=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®exp=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.
diff --git a/app/src/main/assets/searchplugins/ecosia.xml b/app/src/main/assets/searchplugins/ecosia.xml
new file mode 100644
index 000000000..16a58a98e
--- /dev/null
+++ b/app/src/main/assets/searchplugins/ecosia.xml
@@ -0,0 +1,14 @@
+
+
+
+ Ecosia
+ Search Ecosia
+ UTF-8
+ info@ecosia.org
+ Ecosia Search
+ data:image/png;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAAAACMuAAAjLgAAAAAAAAAAAAAAAAAAAAAAAAAAAAC8qzQBuaw3UrmsN6u5rDfruaw37bmsN+25rDfSuaw3fLmsNyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC2rTokrLFGurqsNv+5rDf/uaw3/7msN/+5rDf/uaw3/7urNP/AqS7suqw2aAAAAAAAAAAAAAAAAAAAAAC/qjApkbpn4mvJlf/EqCr/uaw3/7msN/+5rDf/uaw3/7urNP+rsUj/ib5x/7qsNv+9qzKBAAAAAAAAAAC5rDcLwKkvzom9cf813Nb/lrlh/8KoLP+5rDf/uaw3/7msN//BqS3/eMSF/yXj6v+BwHv/lbli/7atO1IAAAAAuaw3bsCqL/+Rumb/K+Di/z3ZzP+dtln/vqox/7msN/+5rDf/waku/23Ikv8s4OH/ONvS/5m4Xv+7qzXZuaw3CbmsN9DBqS7/hL93/zDe3f8v393/RdbD/7OuPv+7qzX/uqw2/8WoKf99wn//Lt/e/y/e3f99wn//v6ow/7msN0+7qzT7s64+/0bWwf8y3tn/L97d/03TuP+usET/vKoz/7isOP+vr0P/XM6n/zDe3P813Nb/L97d/5O6Zf/EpymOu6s0/7OuPv8+2cv/J+Hn/1HStP+0rjz/vasy/76qMP9zxYr/NtzV/zTd1/823NX/NtzV/zLd2f9I1b//mbheqsGpLf+gtVX/bseR/3fEhv+wr0L/vaoy/7msN/+/qjD/Wc+q/yvg4/813Nb/Md7b/zfc1P833NT/Mt7a/zbc1aqHvnT6bMiT/522WP+wr0L/vqox/7msN/+5rDf/vaoy/6C1VP8/2cr/N9zT/2vJlf9hzKD/NtzU/zbc1f813NaONdzWz3HGjv9ky53/prNN/8SoKv+8qzT/uaw3/7msOP/EqCr/ecOE/0HYx/9V0K//N9vT/zXc1v823NX/NtzVTjXc120w3tz/Lt/e/0zUu/+Fv3X/rrBF/7msN/+7qzX/vaoy/6qxSf9G1sH/L9/d/zPd2P8x3tv/L9/e2C/f3Qk23NUKNtzVzDbc1v823NX/OdvQ/0nVvv+xr0H/ta07/7+qL/+7qzT/r69D/2LMoP823NX/VNGx/2TLnVEAAAAAAAAAADbc1Sc03dfgQNnJ/2bKm/862tD/pLRP/1vOqf9S0rP/ib1x/8CpL/+4rDj/qLJM/7qsNn4AAAAAAAAAAAAAAAAAAAAAM93YI0vUvLtux5H/VdGw/3DHj/9Zz6r/Xc2m/3rDgv+5rDf/u6s1672rM2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyaYjUburNaytsUbZuK056cGpLuS/qjDGuaw3gLmsNx4AAAAAAAAAAAAAAAAAAAAA+D8AAOAPAADAAwAAgAMAAIABAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAABAACAAQAAgAMAAMAHAADgDwAA+B8AAA==
+
+
+
diff --git a/app/src/main/assets/searchplugins/reddit.xml b/app/src/main/assets/searchplugins/reddit.xml
new file mode 100644
index 000000000..2930625b1
--- /dev/null
+++ b/app/src/main/assets/searchplugins/reddit.xml
@@ -0,0 +1,11 @@
+
+
+
+ Reddit
+ Search Reddit
+ Reddit Search
+ data:image/png;base64,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
+
+
diff --git a/app/src/main/assets/searchplugins/startpage.xml b/app/src/main/assets/searchplugins/startpage.xml
new file mode 100644
index 000000000..c1b8def0a
--- /dev/null
+++ b/app/src/main/assets/searchplugins/startpage.xml
@@ -0,0 +1,12 @@
+
+
+
+ Startpage.com
+ Startpage.com Search
+ UTF-8
+ data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAACNAAAAjQHGZvekAAAAhklEQVQ4jWNMLf6fwMDAMIGBgYGfgTTwkYGBoYCJTM0MUD0TmMjUDDeEiQLNYDBqAAMD4807///LSjMwcHKQacD/////owveuvOM4fOXH3D+mfN3sWq+dfsZA2NEfAnYgCdPXzA8efqSdBcoaLpiuIAUMDjSAShXkQs+ggwoINOQjwwMDAUAhucl85AOIvIAAAAASUVORK5CYII=
+
+
+
diff --git a/app/src/main/assets/searchplugins/yahoo.xml b/app/src/main/assets/searchplugins/yahoo.xml
new file mode 100644
index 000000000..ba4d1600f
--- /dev/null
+++ b/app/src/main/assets/searchplugins/yahoo.xml
@@ -0,0 +1,15 @@
+
+
+
+ Yahoo
+ Get the best of the web with Yahoo
+ UTF-8
+ Yahoo
+ *
+ data:image/png;base64,AAABAAIAEBAAAAEAIABoBAAAJgAAAAgIAAABACAASAEAAI4EAAAoAAAAEAAAACAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAewE4824BNLJvADJ1cAQ3UW8IOCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwCDknbQQ1UW8AM3VuATSyegE383sAN/91ADT/dQA1/3QANf90ATX/dAI2/3QDNv90Azb/dAM2/3QDNv9zAjb/cwI1/3MBNP93ADb/dAA0/3oAN/99ADj/ewA3/3sBN/99Ajn/gAM7/4IEPf+BBT3/egAy/3kAMv9/BTz/gAU8/30EO/+AAjv/fQE5/3gANv98ADj/fgA4/4IAO/+EAjz/hgM9/4kEQP+LBkH/hwA6//Tl7P/05u3/hgA6/4oGQf+JBUD/iQQ//4cCPf+BATr/fgA4/4EAOf+LAT//jANA/44EQv+SBkT/kgdF/5IAQf/s0t7/7dTf/5IAQf+VCEf/kwdG/5IFRP+PBEH/iQI+/34BOf+AADr/lAFC/5YDRf+ZBUb/mwdJ/50IS/+bA0b/6srZ/+vM2v+bA0b/mwpK/5sISf+bBkj/mQVG/4wDQP+AADr/hgA8/5oCRf+gBEj/owZL/6UITf+nCk//pgVM/+nB0//pwtT/pgVN/6kKUf+lCU7/owdM/54FSP+WA0X/gQE6/4YAPP+fAkj/qgRO/6wHT/+uCFH/rgtS/60BTv/z2eT/8tnl/60CTv+wC1X/rQlS/6wIUP+pBk7/lgRF/4YBPP+HAD3/pwNL/7MFUf+0BlP/tAhU/7cLVv/HQ33////////////KRYD/tgtW/7cJVv+0CFP/rAVP/5sER/+GAT3/iAE9/60CTv++BFb/vwZY/8AIWf+9AFH/9tzn//TX5P/01uT/993p/7wAUv/ACFr/vwdY/7QEUv+hA0n/gwE7/4oAPf+0AlD/xQNZ/8QFWf/EB1v/2ViR///////RQID/0T+A///////ZW5L/xwhd/8YGW/+9BFb/pQJK/4EAOv+NAD//twFS/8gCWv/JBFv/xgBT//34+v/wvdP/xABX/8UBV//wu9P//vn7/8cAVP/KBF3/wANX/6kCTP+BADr/iwA+/7cAUv/HAVr/xQFY/+OIsP//////xBxn/74GWP+/B1n/xBpm///////jirH/xgFZ/8ICV/+vAE//igA9/4gAPP+qAEz/tABR/68AS//SfqT/xlmK/6oBTP+qBU7/qQRN/6gCTP/GV4j/0nui/7EATP+vAU7/mgBF/4MAO/+CADr/jAA+/5wARf+IAD3/jgFA/4ABO/99Ajr/gwI9/4cCPv9/Ajv/jAFA/4oBPv+UAEL/mQBE/4cAPf9/ADn/fAE483oBOLR1ADZ2cwQ3UnAHOCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqCDUndQQ4UoAAOnZ7ATi0fAE48z/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/8AAAoAAAACAAAABAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbwAz/3IANMpzAjWIagApiGoAKYhzAjWIcgA0ym8AM/93ADX/ggI6/4IDPP/Tkq3/05Gs/4IDPP+DAjr/dwA1/38BOPyVBET/lwZH/8uDo//Lg6P/mAZH/5UERP9/ATj8iAE976YFS/+nBU7/4KC4/96ctf+oBU7/pQVM/4gBPe+MAT/vtgVT/7sWW//8+fj/+evw/7sWXP+1BVL/jAE/75UBQ/zFAFX/68LV/9ZYj//WV43/5a3F/8YAVv+UAUL8kwBA/8tUhP/12OX/tQBN/7YATf/22+j/ylGB/5IAQP93ADb/hAA4yoIAMomCAjuHggI6iIIAMoiDADfKdgA2/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/searchplugins/youtube.xml b/app/src/main/assets/searchplugins/youtube.xml
new file mode 100644
index 000000000..34b644daa
--- /dev/null
+++ b/app/src/main/assets/searchplugins/youtube.xml
@@ -0,0 +1,12 @@
+
+
+
+ YouTube
+ Search for videos on YouTube
+ youtube video
+ data:image/png;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAD/EAAA/0AAAP9AAAD/cAAA/4AAAP+AAAD/gAAA/4AAAP+AAAD/QAAA/0AAAP8Q////AP///wD///8AAAD/YAAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA/2D///8AAAD/MAAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD/MAAA/1AAAP//AAD//wAA//8AAP//AAD//wAA//8QEP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA/2AAAP+AAAD//wAA//8AAP//AAD//wAA//8AAP//4OD//1BQ//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP+AAAD/gAAA//8AAP//AAD//wAA//8AAP//AAD/////////////wMD//yAg//8AAP//AAD//wAA//8AAP//AAD/gAAA/4AAAP//AAD//wAA//8AAP//AAD//wAA/////////////7Cw//8gIP//AAD//wAA//8AAP//AAD//wAA/4AAAP+AAAD//wAA//8AAP//AAD//wAA//8AAP//4OD//0BA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP+AAAD/UAAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD/YAAA/zAAAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA/zD///8AAAD/YAAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA/2D///8A////AP///wAAAP8QAAD/QAAA/0AAAP+AAAD/gAAA/4AAAP+AAAD/gAAA/4AAAP9AAAD/QAAA/xD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A//8AAP//AADAAwAAgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAEAAMADAAD//wAA//8AAA==
+
+
+
diff --git a/app/src/main/java/org/mozilla/fenix/components/Search.kt b/app/src/main/java/org/mozilla/fenix/components/Search.kt
index bb024e225..9f974bb46 100644
--- a/app/src/main/java/org/mozilla/fenix/components/Search.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/Search.kt
@@ -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 {
diff --git a/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt b/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt
new file mode 100644
index 000000000..34650f5fb
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt
@@ -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 {
+ 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"
+}
diff --git a/app/src/main/java/org/mozilla/fenix/components/searchengine/FenixSearchEngineProvider.kt b/app/src/main/java/org/mozilla/fenix/components/searchengine/FenixSearchEngineProvider.kt
new file mode 100644
index 000000000..2e5235d94
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/components/searchengine/FenixSearchEngineProvider.kt
@@ -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 {
+ 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"
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt b/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt
new file mode 100644
index 000000000..048acfa38
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt
index 74a9c32f0..e47362137 100644
--- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt
+++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt
@@ -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)
diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt
index 5899bd611..941c9e305 100644
--- a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt
+++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt
@@ -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(
diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt
index 8d9032a55..94d5eb551 100644
--- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt
+++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt
@@ -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,
diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt
index 7ef586da6..50a4641cd 100644
--- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt
+++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt
@@ -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 {
val suggestions = mutableListOf()
- searchEngineManager.getSearchEngines(context).forEach {
+ searchEngineProvider.installedSearchEngines(context).list.forEach {
suggestions.add(
AwesomeBar.Suggestion(
provider = this,
diff --git a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt
index 30ae842b4..1df8b6c62 100644
--- a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt
+++ b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt
@@ -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")
}
/**
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt
new file mode 100644
index 000000000..d9d3cf927
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt
@@ -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 = listOf()
+ private var selectedIndex: Int = -1
+ private val engineViews = mutableListOf()
+
+ 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
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt
new file mode 100644
index 000000000..747a5c5c3
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt
@@ -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().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
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt
index ad2b79302..52d06c6f2 100644
--- a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt
@@ -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(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)
+ }
}
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt
index fc3242dbd..2fb62523e 100644
--- a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt
@@ -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 = 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)
}
}
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineMenu.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineMenu.kt
new file mode 100644
index 000000000..b38568f71
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineMenu.kt
@@ -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()
+
+ 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
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt
new file mode 100644
index 000000000..b2c7d7a39
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/res/layout/custom_search_engine.xml b/app/src/main/res/layout/custom_search_engine.xml
new file mode 100644
index 000000000..bcf5164ac
--- /dev/null
+++ b/app/src/main/res/layout/custom_search_engine.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/custom_search_engine_radio_button.xml b/app/src/main/res/layout/custom_search_engine_radio_button.xml
new file mode 100644
index 000000000..0d03f92d7
--- /dev/null
+++ b/app/src/main/res/layout/custom_search_engine_radio_button.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_add_search_engine.xml b/app/src/main/res/layout/fragment_add_search_engine.xml
new file mode 100644
index 000000000..7a0127fd5
--- /dev/null
+++ b/app/src/main/res/layout/fragment_add_search_engine.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_edit_custom_search_engine.xml b/app/src/main/res/layout/fragment_edit_custom_search_engine.xml
new file mode 100644
index 000000000..9a0c1dc99
--- /dev/null
+++ b/app/src/main/res/layout/fragment_edit_custom_search_engine.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/preference_search_add_engine.xml b/app/src/main/res/layout/preference_search_add_engine.xml
new file mode 100644
index 000000000..74ab71a1b
--- /dev/null
+++ b/app/src/main/res/layout/preference_search_add_engine.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/search_engine_radio_button.xml b/app/src/main/res/layout/search_engine_radio_button.xml
index 9b55bdd26..029c6baf3 100644
--- a/app/src/main/res/layout/search_engine_radio_button.xml
+++ b/app/src/main/res/layout/search_engine_radio_button.xml
@@ -1,4 +1,5 @@
-
@@ -39,4 +40,14 @@
app:layout_constraintStart_toEndOf="@id/engine_icon"
app:layout_constraintTop_toTopOf="@id/engine_icon"
app:layout_constraintEnd_toEndOf="parent" />
+
diff --git a/app/src/main/res/menu/add_custom_searchengine_menu.xml b/app/src/main/res/menu/add_custom_searchengine_menu.xml
new file mode 100644
index 000000000..1336901f6
--- /dev/null
+++ b/app/src/main/res/menu/add_custom_searchengine_menu.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/menu/edit_custom_searchengine_menu.xml b/app/src/main/res/menu/edit_custom_searchengine_menu.xml
new file mode 100644
index 000000000..d25efb733
--- /dev/null
+++ b/app/src/main/res/menu/edit_custom_searchengine_menu.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index 3eca3854b..ea35fa60e 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -428,7 +428,14 @@
+ android:label="@string/preferences_search">
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml
index a4a8a7176..4077fe6eb 100644
--- a/app/src/main/res/values/preference_keys.xml
+++ b/app/src/main/res/values/preference_keys.xml
@@ -5,6 +5,7 @@
pref_key_make_default_browser
pref_key_search_settings
pref_key_search_engine
+ pref_key_add_Search_engine
pref_key_passwords
pref_key_credit_cards_addresses
pref_key_site_permissions
@@ -67,6 +68,7 @@
pref_key_search_widget_installed
+ pref_key_search_engine_list
pref_key_show_search_shortcuts
pref_key_show_search_suggestions
pref_key_show_clipboard_suggestions
diff --git a/app/src/main/res/values/static_strings.xml b/app/src/main/res/values/static_strings.xml
index 2ac1cc510..aacf66eb7 100644
--- a/app/src/main/res/values/static_strings.xml
+++ b/app/src/main/res/values/static_strings.xml
@@ -25,7 +25,6 @@
-
Zoom on all websites
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index aae590cad..63f5cee14 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1014,4 +1014,45 @@
Set up now
Unlock your device
+
+
+ Add search engine
+
+ Edit search engine
+
+ Add
+
+ Save
+
+ Edit
+
+ Delete
+
+
+ Custom
+
+ Name
+
+ Search string to use
+
+ Replace query with “%s”. Example:\nhttps://www.google.com/search?q=%s
+
+ Learn More
+
+
+ Enter search engine name
+
+ Search engine with name “%s” already exists.
+
+ Enter a search string
+
+ Check that search string matches Example format
+
+ Error connecting to “%s”
+
+ Created %s
+
+ Saved %s
+
+ Deleted %s
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index a97f74cb7..ade49adf8 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -354,4 +354,8 @@
+
+
diff --git a/app/src/main/res/xml/search_preferences.xml b/app/src/main/res/xml/search_preferences.xml
index c5adcfbb2..b0d79fe52 100644
--- a/app/src/main/res/xml/search_preferences.xml
+++ b/app/src/main/res/xml/search_preferences.xml
@@ -10,7 +10,12 @@
android:selectable="false"
app:iconSpaceReserved="false">
+