From 607c3d4c873aaf2fb646e50588329a42f324cf01 Mon Sep 17 00:00:00 2001 From: Jeff Boek Date: Tue, 19 Nov 2019 16:30:56 -0800 Subject: [PATCH] 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 --- app/build.gradle | 38 +-- app/src/main/assets/searchplugins/ecosia.xml | 14 + app/src/main/assets/searchplugins/reddit.xml | 11 + .../main/assets/searchplugins/startpage.xml | 12 + app/src/main/assets/searchplugins/yahoo.xml | 15 + app/src/main/assets/searchplugins/youtube.xml | 12 + .../org/mozilla/fenix/components/Search.kt | 9 +- .../searchengine/CustomSearchEngineStore.kt | 130 ++++++++ .../searchengine/FenixSearchEngineProvider.kt | 152 +++++++++ .../searchengine/SearchEngineWriter.kt | 84 +++++ .../org/mozilla/fenix/home/HomeFragment.kt | 5 +- .../mozilla/fenix/search/SearchFragment.kt | 7 +- .../fenix/search/awesomebar/AwesomeBarView.kt | 7 +- .../awesomebar/ShortcutsSuggestionProvider.kt | 6 +- .../mozilla/fenix/settings/SupportUtils.kt | 3 +- .../search/AddSearchEngineFragment.kt | 289 ++++++++++++++++++ .../search/EditCustomSearchEngineFragment.kt | 170 +++++++++++ .../settings/search/SearchEngineFragment.kt | 18 ++ .../search/SearchEngineListPreference.kt | 127 ++++++-- .../fenix/settings/search/SearchEngineMenu.kt | 52 ++++ .../settings/search/SearchStringValidator.kt | 30 ++ .../main/res/layout/custom_search_engine.xml | 75 +++++ .../custom_search_engine_radio_button.xml | 35 +++ .../res/layout/fragment_add_search_engine.xml | 24 ++ .../fragment_edit_custom_search_engine.xml | 11 + .../layout/preference_search_add_engine.xml | 37 +++ .../res/layout/search_engine_radio_button.xml | 13 +- .../res/menu/add_custom_searchengine_menu.xml | 13 + .../menu/edit_custom_searchengine_menu.xml | 13 + app/src/main/res/navigation/nav_graph.xml | 21 +- app/src/main/res/values/preference_keys.xml | 2 + app/src/main/res/values/static_strings.xml | 1 - app/src/main/res/values/strings.xml | 41 +++ app/src/main/res/values/styles.xml | 4 + app/src/main/res/xml/search_preferences.xml | 5 + 35 files changed, 1414 insertions(+), 72 deletions(-) create mode 100644 app/src/main/assets/searchplugins/ecosia.xml create mode 100644 app/src/main/assets/searchplugins/reddit.xml create mode 100644 app/src/main/assets/searchplugins/startpage.xml create mode 100644 app/src/main/assets/searchplugins/yahoo.xml create mode 100644 app/src/main/assets/searchplugins/youtube.xml create mode 100644 app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt create mode 100644 app/src/main/java/org/mozilla/fenix/components/searchengine/FenixSearchEngineProvider.kt create mode 100644 app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineMenu.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt create mode 100644 app/src/main/res/layout/custom_search_engine.xml create mode 100644 app/src/main/res/layout/custom_search_engine_radio_button.xml create mode 100644 app/src/main/res/layout/fragment_add_search_engine.xml create mode 100644 app/src/main/res/layout/fragment_edit_custom_search_engine.xml create mode 100644 app/src/main/res/layout/preference_search_add_engine.xml create mode 100644 app/src/main/res/menu/add_custom_searchengine_menu.xml create mode 100644 app/src/main/res/menu/edit_custom_searchengine_menu.xml 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"> +