Integrate new search code from Android Components into Fenix.
parent
2904ca8ac0
commit
2b759e9d6f
@ -1,37 +0,0 @@
|
|||||||
/* 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
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import mozilla.components.browser.search.SearchEngineManager
|
|
||||||
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
|
|
||||||
import org.mozilla.fenix.perf.lazyMonitored
|
|
||||||
import org.mozilla.fenix.utils.Mockable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component group for all search engine integration related functionality.
|
|
||||||
*/
|
|
||||||
@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 lazyMonitored {
|
|
||||||
SearchEngineManager(
|
|
||||||
coroutineContext = IO,
|
|
||||||
providers = listOf(provider)
|
|
||||||
).apply {
|
|
||||||
registerForLocaleUpdates(context)
|
|
||||||
GlobalScope.launch {
|
|
||||||
defaultSearchEngine = provider.getDefaultEngine(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,70 @@
|
|||||||
|
/* 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.search
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import mozilla.components.browser.search.SearchEngineParser
|
||||||
|
import mozilla.components.browser.state.search.SearchEngine
|
||||||
|
import mozilla.components.feature.search.ext.migrate
|
||||||
|
import mozilla.components.feature.search.middleware.SearchMiddleware
|
||||||
|
import org.mozilla.fenix.ext.components
|
||||||
|
import org.xmlpull.v1.XmlPullParserException
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
private const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines"
|
||||||
|
|
||||||
|
private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines"
|
||||||
|
private const val PREF_KEY_MIGRATED = "pref_search_migrated"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to migrate the search related data in Fenix to the "Android Components" implementation.
|
||||||
|
*/
|
||||||
|
internal class SearchMigration(
|
||||||
|
private val context: Context
|
||||||
|
) : SearchMiddleware.Migration {
|
||||||
|
|
||||||
|
override fun getValuesToMigrate(): SearchMiddleware.Migration.MigrationValues? {
|
||||||
|
val preferences = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE)
|
||||||
|
if (preferences.getBoolean(PREF_KEY_MIGRATED, false)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val values = SearchMiddleware.Migration.MigrationValues(
|
||||||
|
customSearchEngines = loadCustomSearchEngines(preferences),
|
||||||
|
defaultSearchEngineName = context.components.settings.defaultSearchEngineName
|
||||||
|
)
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(PREF_KEY_MIGRATED, true)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCustomSearchEngines(
|
||||||
|
preferences: SharedPreferences
|
||||||
|
): List<SearchEngine> {
|
||||||
|
val ids = preferences.getStringSet(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
val parser = SearchEngineParser()
|
||||||
|
|
||||||
|
return ids.mapNotNull { id ->
|
||||||
|
val xml = preferences.getString(id, null)
|
||||||
|
parser.loadSafely(id, xml?.byteInputStream()?.buffered())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SearchEngineParser.loadSafely(id: String, stream: BufferedInputStream?): SearchEngine? {
|
||||||
|
return try {
|
||||||
|
stream?.let { load(id, it).migrate() }
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null
|
||||||
|
} catch (e: XmlPullParserException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
@ -1,129 +0,0 @@
|
|||||||
/* 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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SearchEngineProvider implementation to load user entered custom search engines.
|
|
||||||
*/
|
|
||||||
class CustomSearchEngineProvider : SearchEngineProvider {
|
|
||||||
override suspend fun loadSearchEngines(context: Context): SearchEngineList {
|
|
||||||
return SearchEngineList(CustomSearchEngineStore.loadCustomSearchEngines(context), null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object to handle storing custom search engines
|
|
||||||
*/
|
|
||||||
object CustomSearchEngineStore {
|
|
||||||
class EngineNameAlreadyExists : Exception()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a search engine to the store.
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
* @param engineName The name of the search engine
|
|
||||||
* @param searchQuery The templated search string for the search engine
|
|
||||||
* @throws EngineNameAlreadyExists if you try to add a search engine that already exists
|
|
||||||
*/
|
|
||||||
suspend fun addSearchEngine(context: Context, engineName: String, searchQuery: String) {
|
|
||||||
val storage = engineStorage(context)
|
|
||||||
if (storage.customSearchEngineIds.contains(engineName)) { throw EngineNameAlreadyExists() }
|
|
||||||
|
|
||||||
val icon = context.components.core.icons.loadIcon(IconRequest(searchQuery)).await()
|
|
||||||
val searchEngineXml = SearchEngineWriter.buildSearchEngineXML(engineName, searchQuery, icon.bitmap)
|
|
||||||
val engines = storage.customSearchEngineIds.toMutableSet()
|
|
||||||
engines.add(engineName)
|
|
||||||
storage.customSearchEngineIds = engines
|
|
||||||
storage[engineName] = searchEngineXml
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an existing search engine.
|
|
||||||
* To prevent duplicate search engines we want to remove the old engine before adding the new one
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
* @param oldEngineName the name of the engine you want to replace
|
|
||||||
* @param newEngineName the name of the engine you want to save
|
|
||||||
* @param searchQuery The templated search string for the search engine
|
|
||||||
*/
|
|
||||||
suspend fun updateSearchEngine(
|
|
||||||
context: Context,
|
|
||||||
oldEngineName: String,
|
|
||||||
newEngineName: String,
|
|
||||||
searchQuery: String
|
|
||||||
) {
|
|
||||||
removeSearchEngine(context, oldEngineName)
|
|
||||||
addSearchEngine(context, newEngineName, searchQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a search engine from the store
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
* @param engineId the id of the engine you want to remove
|
|
||||||
*/
|
|
||||||
fun removeSearchEngine(context: Context, engineId: String) {
|
|
||||||
val storage = engineStorage(context)
|
|
||||||
val customEngines = storage.customSearchEngineIds
|
|
||||||
storage.customSearchEngineIds = customEngines.filterNot { it == engineId }.toSet()
|
|
||||||
storage[engineId] = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the store to see if it contains a search engine
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
* @param engineId The name of the engine to check
|
|
||||||
*/
|
|
||||||
fun isCustomSearchEngine(context: Context, engineId: String): Boolean {
|
|
||||||
val storage = engineStorage(context)
|
|
||||||
return storage.customSearchEngineIds.contains(engineId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a list of [SearchEngine] from the store
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
*/
|
|
||||||
fun loadCustomSearchEngines(context: Context): List<SearchEngine> {
|
|
||||||
val storage = engineStorage(context)
|
|
||||||
val parser = SearchEngineParser()
|
|
||||||
val engines = storage.customSearchEngineIds
|
|
||||||
|
|
||||||
return engines.mapNotNull {
|
|
||||||
val engineXml = storage[it] ?: return@mapNotNull null
|
|
||||||
val engineInputStream = engineXml.byteInputStream().buffered()
|
|
||||||
parser.load(it, engineInputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a helper object to help interact with [SharedPreferences]
|
|
||||||
* @param context [Context] used for various Android interactions.
|
|
||||||
*/
|
|
||||||
private fun engineStorage(context: Context) = object : PreferencesHolder {
|
|
||||||
override val preferences: SharedPreferences
|
|
||||||
get() = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
var customSearchEngineIds by stringSetPreference(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet())
|
|
||||||
|
|
||||||
operator fun get(engineId: String): String? {
|
|
||||||
return preferences.getString(engineId, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun set(engineId: String, value: String?) {
|
|
||||||
preferences.edit().putString(engineId, value).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines"
|
|
||||||
const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines"
|
|
||||||
}
|
|
@ -1,306 +0,0 @@
|
|||||||
/* 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 androidx.annotation.VisibleForTesting
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
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 mozilla.components.browser.search.provider.localization.SearchLocalizationProvider
|
|
||||||
import mozilla.components.service.location.LocationService
|
|
||||||
import mozilla.components.service.location.MozillaLocationService
|
|
||||||
import mozilla.components.service.location.search.RegionSearchLocalizationProvider
|
|
||||||
import org.mozilla.fenix.BuildConfig
|
|
||||||
import org.mozilla.fenix.Config
|
|
||||||
import org.mozilla.fenix.ext.components
|
|
||||||
import org.mozilla.fenix.ext.settings
|
|
||||||
import org.mozilla.fenix.perf.runBlockingIncrement
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@SuppressWarnings("TooManyFunctions")
|
|
||||||
open class FenixSearchEngineProvider(
|
|
||||||
private val context: Context
|
|
||||||
) : SearchEngineProvider, CoroutineScope by CoroutineScope(Job() + Dispatchers.IO) {
|
|
||||||
private val shouldMockMLS = Config.channel.isDebug || BuildConfig.MLS_TOKEN.isEmpty()
|
|
||||||
private val locationService: LocationService = if (shouldMockMLS) {
|
|
||||||
LocationService.dummy()
|
|
||||||
} else {
|
|
||||||
MozillaLocationService(
|
|
||||||
context,
|
|
||||||
context.components.core.client,
|
|
||||||
BuildConfig.MLS_TOKEN
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have two search engine types: one based on MLS reported region, one based only on Locale.
|
|
||||||
// There are multiple steps involved in returning the default search engine for example.
|
|
||||||
// Simplest and most effective way to make sure the MLS engines do not mix with Locale based engines
|
|
||||||
// is to use the same type of engines for the entire duration of the app's run.
|
|
||||||
// See fenix/issues/11875
|
|
||||||
private val isRegionCachedByLocationService = locationService.hasRegionCached()
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
open val localizationProvider: SearchLocalizationProvider =
|
|
||||||
RegionSearchLocalizationProvider(locationService)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unfiltered list of search engines based on locale.
|
|
||||||
*/
|
|
||||||
open var baseSearchEngines = async {
|
|
||||||
AssetsSearchEngineProvider(localizationProvider)
|
|
||||||
.loadSearchEngines(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val loadedRegion = async { localizationProvider.determineRegion() }
|
|
||||||
|
|
||||||
// https://github.com/mozilla-mobile/fenix/issues/9935
|
|
||||||
// Adds a Locale search engine provider as a fallback in case the MLS lookup takes longer
|
|
||||||
// than the time it takes for a user to try to search.
|
|
||||||
private val fallbackLocationService: SearchLocalizationProvider = LocaleSearchLocalizationProvider()
|
|
||||||
private val fallBackProvider =
|
|
||||||
AssetsSearchEngineProvider(fallbackLocationService)
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
open val fallbackEngines = async { fallBackProvider.loadSearchEngines(context) }
|
|
||||||
private val fallbackRegion = async { fallbackLocationService.determineRegion() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default bundled search engines based on locale.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
open val bundledSearchEngines = async {
|
|
||||||
val defaultEngineIdentifiers =
|
|
||||||
baseSearchEngines.await().list.map { it.identifier }.toSet()
|
|
||||||
AssetsSearchEngineProvider(
|
|
||||||
localizationProvider,
|
|
||||||
filters = listOf(object : SearchEngineFilter {
|
|
||||||
override fun filter(context: Context, searchEngine: SearchEngine): Boolean {
|
|
||||||
return BUNDLED_SEARCH_ENGINES.contains(searchEngine.identifier) &&
|
|
||||||
!defaultEngineIdentifiers.contains(searchEngine.identifier)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
additionalIdentifiers = BUNDLED_SEARCH_ENGINES
|
|
||||||
).loadSearchEngines(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search engines that have been manually added by a user.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
open var customSearchEngines = async {
|
|
||||||
CustomSearchEngineProvider().loadSearchEngines(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loadedSearchEngines = refreshInstalledEngineListAsync(baseSearchEngines)
|
|
||||||
|
|
||||||
// https://github.com/mozilla-mobile/fenix/issues/9935
|
|
||||||
// Create new getter that will return the fallback SearchEngineList if
|
|
||||||
// the main one hasn't completed yet
|
|
||||||
private val searchEngines: Deferred<SearchEngineList>
|
|
||||||
get() =
|
|
||||||
if (isRegionCachedByLocationService) {
|
|
||||||
loadedSearchEngines
|
|
||||||
} else {
|
|
||||||
refreshInstalledEngineListAsync(fallbackEngines)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDefaultEngine(context: Context): SearchEngine {
|
|
||||||
val engines = installedSearchEngines(context)
|
|
||||||
val selectedName = context.settings().defaultSearchEngineName
|
|
||||||
|
|
||||||
return engines.list.find { it.name == selectedName }
|
|
||||||
?: engines.default
|
|
||||||
?: engines.list.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should only be setting the default search engine here
|
|
||||||
fun setDefaultEngine(context: Context, id: String) {
|
|
||||||
val engines = installedSearchEngines(context)
|
|
||||||
val newDefault = engines.list.find { it.name == id }
|
|
||||||
?: engines.default
|
|
||||||
?: engines.list.first()
|
|
||||||
|
|
||||||
context.settings().defaultSearchEngineName = newDefault.name
|
|
||||||
context.components.search.searchEngineManager.defaultSearchEngine = newDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return a list of all SearchEngines that are currently active. These are the engines that
|
|
||||||
* are readily available throughout the app. Includes all installed engines, both
|
|
||||||
* default and custom
|
|
||||||
*/
|
|
||||||
fun installedSearchEngines(context: Context): SearchEngineList = runBlockingIncrement {
|
|
||||||
val installedIdentifiers = installedSearchEngineIdentifiers(context)
|
|
||||||
val defaultList = searchEngines.await()
|
|
||||||
|
|
||||||
defaultList.copy(
|
|
||||||
list = defaultList.list.filter {
|
|
||||||
installedIdentifiers.contains(it.identifier)
|
|
||||||
}.sortedBy {
|
|
||||||
it.name.toLowerCase(Locale.getDefault())
|
|
||||||
},
|
|
||||||
default = defaultList.default?.let {
|
|
||||||
if (installedIdentifiers.contains(it.identifier)) {
|
|
||||||
it
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun allSearchEngineIdentifiers() = runBlockingIncrement {
|
|
||||||
loadedSearchEngines.await().list.map { it.identifier }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uninstalledSearchEngines(context: Context): SearchEngineList = runBlockingIncrement {
|
|
||||||
val installedIdentifiers = installedSearchEngineIdentifiers(context)
|
|
||||||
val engineList = loadedSearchEngines.await()
|
|
||||||
|
|
||||||
return@runBlockingIncrement 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,
|
|
||||||
isCustom: Boolean = false
|
|
||||||
) = runBlockingIncrement {
|
|
||||||
if (isCustom) {
|
|
||||||
val searchUrl = searchEngine.getSearchTemplate()
|
|
||||||
CustomSearchEngineStore.addSearchEngine(context, searchEngine.name, searchUrl)
|
|
||||||
reload()
|
|
||||||
} else {
|
|
||||||
val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
|
|
||||||
installedIdentifiers.add(searchEngine.identifier)
|
|
||||||
prefs(context).edit()
|
|
||||||
.putStringSet(
|
|
||||||
localeAwareInstalledEnginesKey(), installedIdentifiers
|
|
||||||
).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uninstallSearchEngine(
|
|
||||||
context: Context,
|
|
||||||
searchEngine: SearchEngine,
|
|
||||||
isCustom: Boolean = false
|
|
||||||
) = runBlockingIncrement {
|
|
||||||
if (isCustom) {
|
|
||||||
CustomSearchEngineStore.removeSearchEngine(context, searchEngine.identifier)
|
|
||||||
reload()
|
|
||||||
} else {
|
|
||||||
val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
|
|
||||||
installedIdentifiers.remove(searchEngine.identifier)
|
|
||||||
prefs(context).edit().putStringSet(
|
|
||||||
localeAwareInstalledEnginesKey(),
|
|
||||||
installedIdentifiers
|
|
||||||
).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reload() {
|
|
||||||
launch {
|
|
||||||
customSearchEngines = async { CustomSearchEngineProvider().loadSearchEngines(context) }
|
|
||||||
loadedSearchEngines = refreshInstalledEngineListAsync(baseSearchEngines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When we change the locale we need to update the baseSearchEngines list
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
open fun updateBaseSearchEngines() {
|
|
||||||
baseSearchEngines = async {
|
|
||||||
AssetsSearchEngineProvider(localizationProvider)
|
|
||||||
.loadSearchEngines(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshInstalledEngineListAsync(
|
|
||||||
engines: Deferred<SearchEngineList>
|
|
||||||
): Deferred<SearchEngineList> = async {
|
|
||||||
val engineList = engines.await()
|
|
||||||
val bundledList = bundledSearchEngines.await().list
|
|
||||||
val customList = customSearchEngines.await().list
|
|
||||||
|
|
||||||
return@async engineList.copy(list = engineList.list + bundledList + customList)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prefs(context: Context) = context.getSharedPreferences(
|
|
||||||
PREF_FILE_SEARCH_ENGINES,
|
|
||||||
Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
suspend fun installedSearchEngineIdentifiers(context: Context): Set<String> {
|
|
||||||
val prefs = prefs(context)
|
|
||||||
val installedEnginesKey = localeAwareInstalledEnginesKey()
|
|
||||||
|
|
||||||
if (!prefs.contains(installedEnginesKey)) {
|
|
||||||
val searchEngines =
|
|
||||||
if (isRegionCachedByLocationService) {
|
|
||||||
baseSearchEngines
|
|
||||||
} else {
|
|
||||||
fallbackEngines
|
|
||||||
}
|
|
||||||
|
|
||||||
val defaultSet = searchEngines.await()
|
|
||||||
.list
|
|
||||||
.map { it.identifier }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
prefs.edit().putStringSet(installedEnginesKey, defaultSet).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
val installedIdentifiers: Set<String> =
|
|
||||||
prefs(context).getStringSet(installedEnginesKey, setOf()) ?: setOf()
|
|
||||||
|
|
||||||
val customEngineIdentifiers =
|
|
||||||
customSearchEngines.await().list.map { it.identifier }.toSet()
|
|
||||||
|
|
||||||
return installedIdentifiers + customEngineIdentifiers
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
suspend fun localeAwareInstalledEnginesKey(): String {
|
|
||||||
val tag = if (isRegionCachedByLocationService) {
|
|
||||||
val localization = loadedRegion.await()
|
|
||||||
val region = localization.region?.let {
|
|
||||||
if (it.isEmpty()) "" else "-$it"
|
|
||||||
}
|
|
||||||
|
|
||||||
"${localization.languageTag}$region"
|
|
||||||
} else {
|
|
||||||
val localization = fallbackRegion.await()
|
|
||||||
val region = localization.region?.let {
|
|
||||||
if (it.isEmpty()) "" else "-$it"
|
|
||||||
}
|
|
||||||
|
|
||||||
"${localization.languageTag}$region-fallback"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "$INSTALLED_ENGINES_KEY-$tag"
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
companion object {
|
|
||||||
val BUNDLED_SEARCH_ENGINES = listOf("reddit", "youtube")
|
|
||||||
const val PREF_FILE_SEARCH_ENGINES = "fenix-search-engine-provider"
|
|
||||||
const val INSTALLED_ENGINES_KEY = "fenix-installed-search-engines"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
/* 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.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import org.w3c.dom.Document
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
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
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
||||||
|
|
||||||
package org.mozilla.fenix.search.ext
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
|
|
||||||
|
|
||||||
private const val MINIMUM_SEARCH_ENGINES_NUMBER_TO_SHOW_SHORTCUTS = 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return if the user has *at least 2* installed search engines.
|
|
||||||
* Useful to decide whether to show / enable certain functionalities.
|
|
||||||
*/
|
|
||||||
fun FenixSearchEngineProvider.areShortcutsAvailable(context: Context) =
|
|
||||||
installedSearchEngines(context).list.size >= MINIMUM_SEARCH_ENGINES_NUMBER_TO_SHOW_SHORTCUTS
|
|
@ -1,242 +0,0 @@
|
|||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
||||||
|
|
||||||
package org.mozilla.fenix.settings.search
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.graphics.drawable.BitmapDrawable
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.CompoundButton
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.RadioGroup
|
|
||||||
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.engine_icon
|
|
||||||
import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_text
|
|
||||||
import kotlinx.android.synthetic.main.search_engine_radio_button.view.overflow_menu
|
|
||||||
import kotlinx.android.synthetic.main.search_engine_radio_button.view.radio_button
|
|
||||||
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.metrics.Event
|
|
||||||
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
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
abstract class SearchEngineListPreference @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = android.R.attr.preferenceStyle
|
|
||||||
) : Preference(context, attrs, defStyleAttr), CompoundButton.OnCheckedChangeListener {
|
|
||||||
|
|
||||||
protected lateinit var searchEngineList: SearchEngineList
|
|
||||||
protected var searchEngineGroup: RadioGroup? = null
|
|
||||||
|
|
||||||
protected abstract val itemResId: Int
|
|
||||||
|
|
||||||
init {
|
|
||||||
layoutResource = R.layout.preference_search_engine_chooser
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: PreferenceViewHolder?) {
|
|
||||||
super.onBindViewHolder(holder)
|
|
||||||
searchEngineGroup = holder!!.itemView.findViewById(R.id.search_engine_group)
|
|
||||||
reload(searchEngineGroup!!.context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reload(context: Context) {
|
|
||||||
searchEngineList = context.components.search.provider.installedSearchEngines(context)
|
|
||||||
refreshSearchEngineViews(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun onSearchEngineSelected(searchEngine: SearchEngine)
|
|
||||||
protected abstract fun updateDefaultItem(defaultButton: CompoundButton)
|
|
||||||
|
|
||||||
private fun refreshSearchEngineViews(context: Context) {
|
|
||||||
if (searchEngineGroup == null) {
|
|
||||||
// We want to refresh the search engine list of this preference in onResume,
|
|
||||||
// but the first time this preference is created onResume is called before onCreateView
|
|
||||||
// so searchEngineGroup is not set yet.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val defaultEngineId = context.components.search.provider.getDefaultEngine(context).identifier
|
|
||||||
|
|
||||||
val selectedEngine = (searchEngineList.list.find {
|
|
||||||
it.identifier == defaultEngineId
|
|
||||||
} ?: searchEngineList.list.first()).identifier
|
|
||||||
|
|
||||||
// set the search engine manager default
|
|
||||||
context.components.search.provider.setDefaultEngine(context, selectedEngine)
|
|
||||||
|
|
||||||
searchEngineGroup!!.removeAllViews()
|
|
||||||
|
|
||||||
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 = context.resources,
|
|
||||||
allowDeletion = searchEngineList.list.size > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
engineItem.id = index + (searchEngineList.default?.let { 1 } ?: 0)
|
|
||||||
engineItem.tag = engineId
|
|
||||||
if (engineId == selectedEngine) {
|
|
||||||
updateDefaultItem(engineItem.radio_button)
|
|
||||||
/* #11465 -> radio_button.isChecked = true does not trigger
|
|
||||||
* onSearchEngineSelected because searchEngineGroup has null views at that point.
|
|
||||||
* So we trigger it here.*/
|
|
||||||
onSearchEngineSelected(engine)
|
|
||||||
}
|
|
||||||
searchEngineGroup!!.addView(engineItem, layoutParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchEngineList.default?.apply {
|
|
||||||
setupSearchEngineItem(0, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchEngineList.list
|
|
||||||
.filter { it.identifier != searchEngineList.default?.identifier }
|
|
||||||
.sortedBy { it.name.toLowerCase(Locale.getDefault()) }
|
|
||||||
.forEachIndexed(setupSearchEngineItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeButtonFromSearchEngine(
|
|
||||||
engine: SearchEngine,
|
|
||||||
layoutInflater: LayoutInflater,
|
|
||||||
res: Resources,
|
|
||||||
allowDeletion: Boolean
|
|
||||||
): View {
|
|
||||||
val isCustomSearchEngine =
|
|
||||||
CustomSearchEngineStore.isCustomSearchEngine(context, engine.identifier)
|
|
||||||
|
|
||||||
val wrapper = layoutInflater.inflate(itemResId, null) as LinearLayout
|
|
||||||
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,
|
|
||||||
isCustomSearchEngine
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).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)
|
|
||||||
wrapper.engine_icon.setImageDrawable(engineIcon)
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
|
||||||
searchEngineList.list.forEach { engine ->
|
|
||||||
val wrapper: LinearLayout =
|
|
||||||
searchEngineGroup?.findViewWithTag(engine.identifier) ?: return
|
|
||||||
|
|
||||||
when (wrapper.radio_button == buttonView) {
|
|
||||||
true -> onSearchEngineSelected(engine)
|
|
||||||
false -> {
|
|
||||||
wrapper.radio_button.setOnCheckedChangeListener(null)
|
|
||||||
wrapper.radio_button.isChecked = false
|
|
||||||
wrapper.radio_button.setOnCheckedChangeListener(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun editCustomSearchEngine(engine: SearchEngine) {
|
|
||||||
val wasDefault = context.components.search.provider.getDefaultEngine(context).identifier == engine.identifier
|
|
||||||
val directions = SearchEngineFragmentDirections
|
|
||||||
.actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.identifier, wasDefault)
|
|
||||||
Navigation.findNavController(searchEngineGroup!!).navigate(directions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteSearchEngine(
|
|
||||||
context: Context,
|
|
||||||
engine: SearchEngine,
|
|
||||||
isCustomSearchEngine: Boolean
|
|
||||||
) {
|
|
||||||
val isDefaultEngine = engine == context.components.search.provider.getDefaultEngine(context)
|
|
||||||
val initialEngineList = searchEngineList.copy()
|
|
||||||
val initialDefaultEngine = searchEngineList.default
|
|
||||||
|
|
||||||
context.components.search.provider.uninstallSearchEngine(
|
|
||||||
context,
|
|
||||||
engine,
|
|
||||||
isCustomSearchEngine
|
|
||||||
)
|
|
||||||
|
|
||||||
MainScope().allowUndo(
|
|
||||||
view = context.getRootView()!!,
|
|
||||||
message = context
|
|
||||||
.getString(R.string.search_delete_search_engine_success_message, engine.name),
|
|
||||||
undoActionTitle = context.getString(R.string.snackbar_deleted_undo),
|
|
||||||
onCancel = {
|
|
||||||
context.components.search.provider.installSearchEngine(
|
|
||||||
context,
|
|
||||||
engine,
|
|
||||||
isCustomSearchEngine
|
|
||||||
)
|
|
||||||
|
|
||||||
searchEngineList = initialEngineList.copy(
|
|
||||||
default = initialDefaultEngine
|
|
||||||
)
|
|
||||||
|
|
||||||
refreshSearchEngineViews(context)
|
|
||||||
},
|
|
||||||
operation = {
|
|
||||||
if (isDefaultEngine) {
|
|
||||||
val default = context.components.search.provider.getDefaultEngine(context)
|
|
||||||
context.components.search.provider.setDefaultEngine(context, default.identifier)
|
|
||||||
context.settings().defaultSearchEngineName = default.name
|
|
||||||
}
|
|
||||||
if (isCustomSearchEngine) {
|
|
||||||
context.components.analytics.metrics.track(Event.CustomEngineDeleted)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,207 +0,0 @@
|
|||||||
/* 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.metrics
|
|
||||||
|
|
||||||
import mozilla.components.browser.search.SearchEngine
|
|
||||||
import mozilla.components.browser.search.SearchEngineManager
|
|
||||||
import mozilla.components.browser.search.provider.AssetsSearchEngineProvider
|
|
||||||
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
|
|
||||||
import mozilla.components.support.test.robolectric.testContext
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.mozilla.fenix.components.metrics.Event.PerformedSearch
|
|
||||||
import org.mozilla.fenix.components.metrics.Event.PerformedSearch.EngineSource
|
|
||||||
import org.mozilla.fenix.components.metrics.Event.PerformedSearch.EventSource
|
|
||||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
|
||||||
|
|
||||||
@RunWith(FenixRobolectricTestRunner::class)
|
|
||||||
class PerformedSearchTest {
|
|
||||||
private lateinit var searchEngines: List<SearchEngine>
|
|
||||||
|
|
||||||
// Match against the Regex defined at
|
|
||||||
// https://github.com/mozilla-mobile/android-components/blob/master/components/service/glean/src/main/java/mozilla/components/service/glean/private/LabeledMetricType.kt#L43
|
|
||||||
// We're temporarily using it until better Glean testing APIs are available.
|
|
||||||
private val countLabelRegex = Regex("^[a-z_][a-z0-9_-]{0,29}(\\.[a-z0-9_-]{0,29})*$")
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
searchEngines = SearchEngineManager(listOf(provider)).getSearchEngines(testContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testThatCountLabelIsValid() {
|
|
||||||
val labels = searchEngines.map {
|
|
||||||
PerformedSearch(EventSource.Action(EngineSource.Shortcut(it, false))).eventSource.countLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
labels.forEach {
|
|
||||||
assertTrue("$it does not match!", it.matches(countLabelRegex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val provider = AssetsSearchEngineProvider(
|
|
||||||
localizationProvider = LocaleSearchLocalizationProvider(),
|
|
||||||
additionalIdentifiers = listOf(
|
|
||||||
"amazon-au",
|
|
||||||
"amazon-br",
|
|
||||||
"amazon-ca",
|
|
||||||
"amazon-co-uk",
|
|
||||||
"amazon-de",
|
|
||||||
"amazon-fr",
|
|
||||||
"amazon-in",
|
|
||||||
"amazon-it",
|
|
||||||
"amazon-jp",
|
|
||||||
"amazon-mx",
|
|
||||||
"amazon-nl",
|
|
||||||
"amazondotcom",
|
|
||||||
"azerdict",
|
|
||||||
"azet-sk",
|
|
||||||
"baidu",
|
|
||||||
"bing",
|
|
||||||
"bolcom-fy-NL",
|
|
||||||
"bolcom-nl",
|
|
||||||
"ceneje",
|
|
||||||
"coccoc",
|
|
||||||
"danawa-kr",
|
|
||||||
"daum-kr",
|
|
||||||
"ddg",
|
|
||||||
"diec2",
|
|
||||||
"drae",
|
|
||||||
"duckduckgo",
|
|
||||||
"elebila",
|
|
||||||
"faclair-beag",
|
|
||||||
"google-2018",
|
|
||||||
"google-b-1-m",
|
|
||||||
"google-b-m",
|
|
||||||
"google",
|
|
||||||
"gulesider-mobile-NO",
|
|
||||||
"heureka-cz",
|
|
||||||
"hotline-ua",
|
|
||||||
"leit-is",
|
|
||||||
"leo_ende_de",
|
|
||||||
"list-am",
|
|
||||||
"mapy-cz",
|
|
||||||
"mercadolibre-ar",
|
|
||||||
"mercadolibre-cl",
|
|
||||||
"mercadolibre-mx",
|
|
||||||
"naver-kr",
|
|
||||||
"odpiralni",
|
|
||||||
"pazaruvaj",
|
|
||||||
"pledarigrond",
|
|
||||||
"prisjakt-sv-SE",
|
|
||||||
"qwant",
|
|
||||||
"rediff",
|
|
||||||
"reta-vortaro",
|
|
||||||
"salidzinilv",
|
|
||||||
"seznam-cz",
|
|
||||||
"skroutz",
|
|
||||||
"slovnik-sk",
|
|
||||||
"sslv",
|
|
||||||
"sztaki-en-hu",
|
|
||||||
"taobao",
|
|
||||||
"tearma",
|
|
||||||
"twitter-ja",
|
|
||||||
"twitter",
|
|
||||||
"vatera",
|
|
||||||
"wikipedia-NN",
|
|
||||||
"wikipedia-NO",
|
|
||||||
"wikipedia-an",
|
|
||||||
"wikipedia-ar",
|
|
||||||
"wikipedia-as",
|
|
||||||
"wikipedia-ast",
|
|
||||||
"wikipedia-az",
|
|
||||||
"wikipedia-be",
|
|
||||||
"wikipedia-bg",
|
|
||||||
"wikipedia-bn",
|
|
||||||
"wikipedia-br",
|
|
||||||
"wikipedia-bs",
|
|
||||||
"wikipedia-ca",
|
|
||||||
"wikipedia-cy",
|
|
||||||
"wikipedia-cz",
|
|
||||||
"wikipedia-da",
|
|
||||||
"wikipedia-de",
|
|
||||||
"wikipedia-dsb",
|
|
||||||
"wikipedia-el",
|
|
||||||
"wikipedia-eo",
|
|
||||||
"wikipedia-es",
|
|
||||||
"wikipedia-et",
|
|
||||||
"wikipedia-eu",
|
|
||||||
"wikipedia-fa",
|
|
||||||
"wikipedia-fi",
|
|
||||||
"wikipedia-fr",
|
|
||||||
"wikipedia-fy-NL",
|
|
||||||
"wikipedia-ga-IE",
|
|
||||||
"wikipedia-gd",
|
|
||||||
"wikipedia-gl",
|
|
||||||
"wikipedia-gn",
|
|
||||||
"wikipedia-gu",
|
|
||||||
"wikipedia-he",
|
|
||||||
"wikipedia-hi",
|
|
||||||
"wikipedia-hr",
|
|
||||||
"wikipedia-hsb",
|
|
||||||
"wikipedia-hu",
|
|
||||||
"wikipedia-hy-AM",
|
|
||||||
"wikipedia-ia",
|
|
||||||
"wikipedia-id",
|
|
||||||
"wikipedia-is",
|
|
||||||
"wikipedia-it",
|
|
||||||
"wikipedia-ja",
|
|
||||||
"wikipedia-ka",
|
|
||||||
"wikipedia-kab",
|
|
||||||
"wikipedia-kk",
|
|
||||||
"wikipedia-km",
|
|
||||||
"wikipedia-kn",
|
|
||||||
"wikipedia-lij",
|
|
||||||
"wikipedia-lo",
|
|
||||||
"wikipedia-lt",
|
|
||||||
"wikipedia-ltg",
|
|
||||||
"wikipedia-lv",
|
|
||||||
"wikipedia-ml",
|
|
||||||
"wikipedia-mr",
|
|
||||||
"wikipedia-ms",
|
|
||||||
"wikipedia-my",
|
|
||||||
"wikipedia-ne",
|
|
||||||
"wikipedia-nl",
|
|
||||||
"wikipedia-oc",
|
|
||||||
"wikipedia-or",
|
|
||||||
"wikipedia-pa",
|
|
||||||
"wikipedia-pl",
|
|
||||||
"wikipedia-pt",
|
|
||||||
"wikipedia-rm",
|
|
||||||
"wikipedia-ro",
|
|
||||||
"wikipedia-ru",
|
|
||||||
"wikipedia-sk",
|
|
||||||
"wikipedia-sl",
|
|
||||||
"wikipedia-sq",
|
|
||||||
"wikipedia-sr",
|
|
||||||
"wikipedia-sv-SE",
|
|
||||||
"wikipedia-ta",
|
|
||||||
"wikipedia-te",
|
|
||||||
"wikipedia-th",
|
|
||||||
"wikipedia-tr",
|
|
||||||
"wikipedia-uk",
|
|
||||||
"wikipedia-ur",
|
|
||||||
"wikipedia-uz",
|
|
||||||
"wikipedia-vi",
|
|
||||||
"wikipedia-wo",
|
|
||||||
"wikipedia-zh-CN",
|
|
||||||
"wikipedia-zh-TW",
|
|
||||||
"wikipedia",
|
|
||||||
"wiktionary-kn",
|
|
||||||
"wiktionary-oc",
|
|
||||||
"wiktionary-or",
|
|
||||||
"wiktionary-ta",
|
|
||||||
"wiktionary-te",
|
|
||||||
"yahoo-jp",
|
|
||||||
"yandex-en",
|
|
||||||
"yandex-ru",
|
|
||||||
"yandex-tr",
|
|
||||||
"yandex.by",
|
|
||||||
"yandex"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,174 +0,0 @@
|
|||||||
package org.mozilla.fenix.components.searchengine
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import io.mockk.Runs
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.coVerify
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.just
|
|
||||||
import io.mockk.mockk
|
|
||||||
import io.mockk.mockkObject
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runBlockingTest
|
|
||||||
import mozilla.components.browser.search.SearchEngine
|
|
||||||
import mozilla.components.browser.search.provider.SearchEngineList
|
|
||||||
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
|
|
||||||
import mozilla.components.browser.search.provider.localization.SearchLocalizationProvider
|
|
||||||
import mozilla.components.support.test.robolectric.testContext
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
|
||||||
@RunWith(FenixRobolectricTestRunner::class)
|
|
||||||
class FenixSearchEngineProviderTest {
|
|
||||||
|
|
||||||
private lateinit var fenixSearchEngineProvider: FenixSearchEngineProvider
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun before() {
|
|
||||||
fenixSearchEngineProvider = FakeFenixSearchEngineProvider(testContext)
|
|
||||||
mockkObject(CustomSearchEngineStore)
|
|
||||||
fenixSearchEngineProvider.let {
|
|
||||||
every { CustomSearchEngineStore.loadCustomSearchEngines(testContext) } returns listOf(
|
|
||||||
(it as FakeFenixSearchEngineProvider)
|
|
||||||
.mockSearchEngine("my custom site", "my custom site")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
TODO TEST:
|
|
||||||
- public API happy path
|
|
||||||
- list ordering
|
|
||||||
- deduping
|
|
||||||
- the above after adding/removing
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
@Test
|
|
||||||
fun `add custom engine`() = runBlockingTest {
|
|
||||||
val engineName = "Ecosia"
|
|
||||||
val engineQuery = "www.ecosia.com/%s"
|
|
||||||
val searchEngine: SearchEngine = mockk(relaxed = true)
|
|
||||||
every { searchEngine.getSearchTemplate() } returns engineQuery
|
|
||||||
every { searchEngine.name } returns engineName
|
|
||||||
mockkObject(CustomSearchEngineStore)
|
|
||||||
coEvery {
|
|
||||||
CustomSearchEngineStore.addSearchEngine(
|
|
||||||
testContext,
|
|
||||||
engineName,
|
|
||||||
engineQuery
|
|
||||||
)
|
|
||||||
} just Runs
|
|
||||||
|
|
||||||
fenixSearchEngineProvider.installSearchEngine(testContext, searchEngine, true)
|
|
||||||
|
|
||||||
coVerify { CustomSearchEngineStore.addSearchEngine(testContext, engineName, engineQuery) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN sharedprefs does not contain installed engines WHEN installedSearchEngineIdentifiers THEN defaultEngines + customEngines ids are returned`() = runBlockingTest {
|
|
||||||
val expectedDefaults = fenixSearchEngineProvider.baseSearchEngines.toIdSet()
|
|
||||||
val expectedCustom = fenixSearchEngineProvider.customSearchEngines.toIdSet()
|
|
||||||
val expected = expectedDefaults + expectedCustom
|
|
||||||
|
|
||||||
val actual = fenixSearchEngineProvider.installedSearchEngineIdentifiers(testContext)
|
|
||||||
assertEquals(expected, actual)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN sharedprefs contains installed engines WHEN installedSearchEngineIdentifiers THEN defaultEngines + customEngines ids are returned`() = runBlockingTest {
|
|
||||||
val sp = testContext.getSharedPreferences(
|
|
||||||
FenixSearchEngineProvider.PREF_FILE_SEARCH_ENGINES,
|
|
||||||
Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
sp.edit().putStringSet(
|
|
||||||
fenixSearchEngineProvider.localeAwareInstalledEnginesKey(),
|
|
||||||
persistedInstalledEngines
|
|
||||||
).apply()
|
|
||||||
|
|
||||||
val expectedStored = persistedInstalledEngines
|
|
||||||
val expectedCustom = fenixSearchEngineProvider.customSearchEngines.toIdSet()
|
|
||||||
val expected = expectedStored + expectedCustom
|
|
||||||
|
|
||||||
val actual = fenixSearchEngineProvider.installedSearchEngineIdentifiers(testContext)
|
|
||||||
assertEquals(expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun Deferred<SearchEngineList>.toIdSet() =
|
|
||||||
await().list.map { it.identifier }.toSet()
|
|
||||||
|
|
||||||
private val persistedInstalledEngines = setOf("bing", "ecosia")
|
|
||||||
|
|
||||||
class FakeFenixSearchEngineProvider(context: Context) : FenixSearchEngineProvider(context) {
|
|
||||||
override val localizationProvider: SearchLocalizationProvider
|
|
||||||
get() = LocaleSearchLocalizationProvider()
|
|
||||||
|
|
||||||
override var baseSearchEngines: Deferred<SearchEngineList>
|
|
||||||
set(_) { throw NotImplementedError("Setting not currently supported on this fake") }
|
|
||||||
get() {
|
|
||||||
val google = mockSearchEngine(id = "google-b-1-m", n = "Google")
|
|
||||||
|
|
||||||
return CompletableDeferred(
|
|
||||||
SearchEngineList(
|
|
||||||
listOf(
|
|
||||||
google,
|
|
||||||
mockSearchEngine("bing", "Bing"),
|
|
||||||
mockSearchEngine("amazondotcom", "Amazon.com")
|
|
||||||
), default = google
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val fallbackEngines: Deferred<SearchEngineList>
|
|
||||||
get() {
|
|
||||||
val google = mockSearchEngine(id = "google-b-1-m", n = "Google")
|
|
||||||
|
|
||||||
return CompletableDeferred(
|
|
||||||
SearchEngineList(
|
|
||||||
listOf(
|
|
||||||
google,
|
|
||||||
mockSearchEngine("bing", "Bing"),
|
|
||||||
mockSearchEngine("amazondotcom", "Amazon.com")
|
|
||||||
), default = google
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val bundledSearchEngines = CompletableDeferred(
|
|
||||||
SearchEngineList(
|
|
||||||
listOf(
|
|
||||||
mockSearchEngine("ecosia", "Ecosia"),
|
|
||||||
mockSearchEngine("reddit", "Reddit"),
|
|
||||||
mockSearchEngine("startpage", "Startpage.com")
|
|
||||||
), default = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override var customSearchEngines: Deferred<SearchEngineList> = CompletableDeferred(
|
|
||||||
SearchEngineList(
|
|
||||||
listOf(
|
|
||||||
mockSearchEngine("my custom site", "my custom site")
|
|
||||||
), default = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun updateBaseSearchEngines() { }
|
|
||||||
|
|
||||||
fun mockSearchEngine(
|
|
||||||
id: String,
|
|
||||||
n: String = id
|
|
||||||
): SearchEngine {
|
|
||||||
val engine = mockk<SearchEngine>()
|
|
||||||
every { engine.identifier } returns id
|
|
||||||
every { engine.name } returns n
|
|
||||||
every { engine.icon } returns mockk()
|
|
||||||
return engine
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue