Commit d102b22b authored by Sebastian Kaspari's avatar Sebastian Kaspari Committed by mergify[bot]
Browse files

Remove browser-search and migrate leftover functionality to feature-search.

Now that Fenix and Focus have migrated to using search functionality from the state in
BrowserStore (provided by feature-search), we can remove browser-search. The client
for search suggestions and the assets still lived in browser-search and this patch
moves those to feature-search too.
parent 16520b47
......@@ -211,10 +211,6 @@ projects:
path: components/browser/menu2
description: 'An immutable customizable menu for browsers.'
publish: true
browser-search:
path: components/browser/search
description: 'Search plugins and companion code to load, parse and use them.'
publish: true
browser-session:
path: components/browser/session
description: 'An abstract layer hiding the actual browser engine implementation.'
......
......@@ -80,8 +80,6 @@ High-level components for building browser(-like) apps.
*[**Menu 2**](components/browser/menu2/README.md) - A generic menu with customizable items primarily for browser toolbars.
* 🔵 [**Search**](components/browser/search/README.md) - Search plugins and companion code to load, parse and use them.
* 🔵 [**Session**](components/browser/session/README.md) - A generic representation of a browser session.
* 🔵 [**Session-Storage**](components/browser/session-storage/README.md) - Component for saving and restoring the browser state.
......
# [Android Components](../../../README.md) > Browser > Search
Search plugins and companion code to load, parse and use them.
## Usage
### Setting up the dependency
Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
```Groovy
implementation "org.mozilla.components:browser-search:{latest-version}"
```
## License
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/
/* 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/. */
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion config.compileSdkVersion
defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
sourceSets {
test {
resources {
// We want to access the assets from unit tests. With this configuration we can just
// read the files directly and do not need to rely on Robolectric.
srcDir "${projectDir}/src/main/assets/"
}
}
}
}
dependencies {
implementation project(':support-ktx')
implementation project(':support-base')
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
testImplementation project(':support-test')
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_coroutines
testImplementation Dependencies.testing_mockito
testImplementation Dependencies.testing_robolectric
}
apply from: '../../../publish.gradle'
ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
<!-- 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/. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="mozilla.components.browser.search" />
/* 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 mozilla.components.browser.search
/**
* Interface for a class that can provide a default search engine.
*
* This interface is a temporary workaround to allow applications to switch to the new API slowly.
* Once all consuming apps have been migrated this interface will be removed and all components
* will be migrated to use the state in `BrowserStore` directly.
*
* https://github.com/mozilla-mobile/android-components/issues/8686
*/
interface DefaultSearchEngineProvider {
/**
* Returns the default search engine for the user; or `null` if no default search engine is
* available.
*/
fun getDefaultSearchEngine(): SearchEngine?
/**
* Returns the default search engine for that user; or `null` if no default search engine is
* available. Other than [getDefaultSearchEngine] this method may suspend.
*/
suspend fun retrieveDefaultSearchEngine(): SearchEngine?
}
/* 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 mozilla.components.browser.search
import android.graphics.Bitmap
import android.net.Uri
import android.text.TextUtils
import java.util.Locale
/**
* A data class representing a search engine.
*/
class SearchEngine(
val identifier: String,
val name: String,
val icon: Bitmap,
val resultsUris: List<Uri>,
val suggestUri: Uri? = null
) {
val canProvideSearchSuggestions: Boolean = suggestUri != null
init {
if (resultsUris.isEmpty()) {
throw IllegalArgumentException("Results uri list should not be empty!")
}
}
/**
* Gets the user-entered search url template
*/
fun getSearchTemplate(): String {
val template = Uri.decode(resultsUris[0].toString())
return paramSubstitution(template, USER_QUERY_TEMPLATE)
}
/**
* Builds a URL to search for the given search terms with this search engine.
*/
fun buildSearchUrl(searchTerm: String): String {
// The parse should have put the best URL for this device at the beginning of the list.
val searchUri = resultsUris[0]
return buildURL(searchUri, searchTerm)
}
/**
* Builds a URL to get suggestions from this search engine.
*/
fun buildSuggestionsURL(searchTerm: String): String? {
val suggestUri = suggestUri ?: return null
return buildURL(suggestUri, searchTerm)
}
private fun buildURL(uri: Uri, searchTerm: String): String {
val template = Uri.decode(uri.toString())
val urlWithSubstitutions = paramSubstitution(template, Uri.encode(searchTerm))
return normalize(urlWithSubstitutions) // User-entered search engines may need normalization.
}
/**
* Formats template string with proper parameters. Modeled after ParamSubstitution in nsSearchService.js
*/
private fun paramSubstitution(template: String, query: String): String {
var result = template
val locale = Locale.getDefault().toString()
result = result.replace(MOZ_PARAM_LOCALE, locale)
result = result.replace(MOZ_PARAM_DIST_ID, "")
result = result.replace(MOZ_PARAM_OFFICIAL, "unofficial")
result = result.replace(OS_PARAM_USER_DEFINED, query)
result = result.replace(OS_PARAM_INPUT_ENCODING, "UTF-8")
result = result.replace(OS_PARAM_LANGUAGE, locale)
result = result.replace(OS_PARAM_OUTPUT_ENCODING, "UTF-8")
// Replace any optional parameters
result = result.replace(OS_PARAM_OPTIONAL.toRegex(), "")
return result
}
companion object {
// We are using string concatenation here to avoid the Kotlin compiler interpreting this
// as string templates. It is possible to escape the string accordingly. But this seems to
// be inconsistent between Kotlin versions. So to be safe we avoid this completely by
// constructing the strings manually.
// Parameters copied from nsSearchService.js
private const val MOZ_PARAM_LOCALE = "{" + "moz:locale" + "}"
private const val MOZ_PARAM_DIST_ID = "{" + "moz:distributionID" + "}"
private const val MOZ_PARAM_OFFICIAL = "{" + "moz:official" + "}"
// Supported OpenSearch parameters
// See http://opensearch.a9.com/spec/1.1/querysyntax/#core
private const val OS_PARAM_USER_DEFINED = "{" + "searchTerms" + "}"
private const val OS_PARAM_INPUT_ENCODING = "{" + "inputEncoding" + "}"
private const val OS_PARAM_LANGUAGE = "{" + "language" + "}"
private const val OS_PARAM_OUTPUT_ENCODING = "{" + "outputEncoding" + "}"
private const val OS_PARAM_OPTIONAL = "\\{" + "(?:\\w+:)?\\w+?" + "\\}"
private const val USER_QUERY_TEMPLATE = "%s"
private fun normalize(input: String): String {
val trimmedInput = input.trim { it <= ' ' }
var uri = Uri.parse(trimmedInput)
if (TextUtils.isEmpty(uri.scheme)) {
uri = Uri.parse("http://$trimmedInput")
}
return uri.toString()
}
}
override fun toString(): String = "SearchEngine($identifier)"
}
/* 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 mozilla.components.browser.search
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
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.localization.LocaleSearchLocalizationProvider
import kotlin.coroutines.CoroutineContext
/**
* This class provides access to a centralized registry of search engines.
*/
class SearchEngineManager(
private val providers: List<SearchEngineProvider> = listOf(
AssetsSearchEngineProvider(LocaleSearchLocalizationProvider())),
coroutineContext: CoroutineContext = Dispatchers.IO
) {
private var deferredSearchEngines: Deferred<SearchEngineList>? = null
private val scope = CoroutineScope(coroutineContext)
/**
* This is set by browsers to indicate the users preference of which search engine to use.
* This overrides the default which may be set by the [SearchEngineProvider] (e.g. via `list.json`)
*/
var defaultSearchEngine: SearchEngine? = null
/**
* Asynchronously load search engines from providers. Inherits caller's [CoroutineContext].
*/
@Synchronized
suspend fun loadAsync(context: Context): Deferred<SearchEngineList> = coroutineScope {
deferredSearchEngines ?: scope.async {
loadSearchEngines(context)
}.also {
deferredSearchEngines = it
}
}
/**
* Asynchronously load search engines from providers. Inherits caller's [CoroutineContext].
*/
@Synchronized
@Suppress("DeferredIsResult")
@Deprecated("Use `loadAsync` instead", ReplaceWith("loadAsync(context)"))
// TODO remove it from public API
suspend fun load(context: Context): Deferred<SearchEngineList> = loadAsync(context)
/**
* Gets the localized list of search engines and a default search engine from providers.
*
* If no previous call was made to [load] or [loadAsync] then calling this method will
* perform a blocking load.
*/
private fun getSearchEngineList(context: Context): SearchEngineList = runBlocking {
getSearchEngineListAsync(context)
}
/**
* Gets the localized list of search engines and a default search engine from providers.
*
* If no previous call was made to [load] or [loadAsync] then calling this method will perform
* a load asynchronously.
*/
private suspend fun getSearchEngineListAsync(context: Context): SearchEngineList =
loadAsync(context).await()
/**
* Returns all search engines.
*/
@Synchronized
fun getSearchEngines(context: Context): List<SearchEngine> {
return getSearchEngineList(context).list
}
/**
* Returns all search engines.
*/
suspend fun getSearchEnginesAsync(context: Context): List<SearchEngine> {
return getSearchEngineListAsync(context).list
}
/**
* Returns the default search engine.
*
* If defaultSearchEngine has not been set, the default engine is set by the search provider,
* (e.g. as set in `list.json`). If that is not set, then the first search engine listed is
* returned.
*
* Optionally a name can be passed to this method (e.g. from the user's preferences). If
* a matching search engine was loaded then this search engine will be returned instead.
*/
@Synchronized
fun getDefaultSearchEngine(context: Context, name: String = EMPTY): SearchEngine {
val searchEngineList = getSearchEngineList(context)
val providedDefault = getProvidedDefaultSearchEngine(context)
return when (name) {
EMPTY -> defaultSearchEngine ?: providedDefault
else -> searchEngineList.list.find { it.name == name } ?: providedDefault
}
}
/**
* Returns the default search engine.
*
* If defaultSearchEngine has not been set, the default engine is set by the search provider,
* (e.g. as set in `list.json`). If that is not set, then the first search engine listed is
* returned.
*
* Optionally a name can be passed to this method (e.g. from the user's preferences). If
* a matching search engine was loaded then this search engine will be returned instead.
*/
suspend fun getDefaultSearchEngineAsync(context: Context, name: String = EMPTY): SearchEngine {
val searchEngineList = getSearchEngineListAsync(context)
val providedDefault = getProvidedDefaultSearchEngineAsync(context)
return when (name) {
EMPTY -> defaultSearchEngine ?: providedDefault
else -> searchEngineList.list.find { it.name == name } ?: providedDefault
}
}
/**
* Returns the provided default search engine or the first search engine if the default
* is not set.
*/
@Synchronized
fun getProvidedDefaultSearchEngine(context: Context): SearchEngine {
val searchEngineList = getSearchEngineList(context)
return searchEngineList.default ?: searchEngineList.list[0]
}
/**
* Returns the provided default search engine or the first search engine if the default
* is not set.
*/
suspend fun getProvidedDefaultSearchEngineAsync(context: Context): SearchEngine {
val searchEngineList = getSearchEngineListAsync(context)
return searchEngineList.default ?: searchEngineList.list[0]
}
/**
* Registers for ACTION_LOCALE_CHANGED broadcasts and automatically reloads the search engines
* whenever the locale changes.
*/
fun registerForLocaleUpdates(context: Context) {
context.registerReceiver(localeChangedReceiver, IntentFilter(Intent.ACTION_LOCALE_CHANGED))
}
/**
* Loads the search engines and defaults from all search engine providers. Some attempt is made
* to merge these lists. As there can only be one default, the first provider with a default
* gets to set this [SearchEngineManager] default.
*/
private suspend fun loadSearchEngines(context: Context): SearchEngineList {
val deferredSearchEngines = providers.map {
scope.async {
it.loadSearchEngines(context)
}
}
val searchEngineLists =
deferredSearchEngines.map { it.await() }
val searchEngines = searchEngineLists
.fold(emptyList<SearchEngine>()) { sum, searchEngineList ->
sum + searchEngineList.list
}
.distinctBy { it.name }
val defaultSearchEngine = searchEngineLists
.firstOrNull { it.default != null }?.default
return SearchEngineList(searchEngines, defaultSearchEngine)
}
internal val localeChangedReceiver by lazy {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
scope.launch {
loadAsync(context.applicationContext).await()
}
}
}
}
companion object {
private const val EMPTY = ""
}
}
/* 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 mozilla.components.browser.search
import android.content.res.AssetManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
/**
* A very simple parser for search plugins.
*/
class SearchEngineParser {
private class SearchEngineBuilder(
private val identifier: String
) {
var resultsUris: MutableList<Uri> = mutableListOf()
var suggestUri: Uri? = null
var name: String? = null
var icon: Bitmap? = null
fun toSearchEngine() = SearchEngine(
identifier,
name!!,
icon!!,
resultsUris,
suggestUri
)
}
/**
* Loads a <code>SearchEngine</code> from the given <code>path</code> in assets and assigns
* it the given <code>identifier</code>.
*/
@Throws(IOException::class)
fun load(assetManager: AssetManager, identifier: String, path: String): SearchEngine {
try {
assetManager.open(path).use { stream -> return load(identifier, stream) }
} catch (e: XmlPullParserException) {
throw AssertionError("Parser exception while reading $path", e)
}
}
/**
* Loads a <code>SearchEngine</code> from the given <code>stream</code> and assigns it the given
* <code>identifier</code>.
*/
@Throws(IOException::class, XmlPullParserException::class)
fun load(identifier: String, stream: InputStream): SearchEngine {
val builder = SearchEngineBuilder(identifier)
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(InputStreamReader(stream, StandardCharsets.UTF_8))
parser.next()
readSearchPlugin(parser, builder)
return builder.toSearchEngine()
}
@Throws(XmlPullParserException::class, IOException::class)
private fun readSearchPlugin(parser: XmlPullParser, builder: SearchEngineBuilder) {
if (XmlPullParser.START_TAG != parser.eventType) {
throw XmlPullParserException("Expected start tag: " + parser.positionDescription)
}
val name = parser.name
if ("SearchPlugin" != name && "OpenSearchDescription" != name) {
throw XmlPullParserException(
"Expected <SearchPlugin> or <OpenSearchDescription> as root tag: ${parser.positionDescription}")
}
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
when (parser.name) {
"ShortName" -> readShortName(parser, builder)
"Url" -> readUrl(parser, builder)