Commit 0c1233a9 authored by MozLando's avatar MozLando
Browse files

Merge #7713



7713: Issue #7712: Add flag for filtering exact matches from SearchSuggestionProvider and add SearchActionProvider. r=csadilek a=pocmo

This allows a consuming app to filter suggestions that match the entered text from `SearchSuggestionProvider`. Then they can add `SearchActionProvider` that provides a suggestion for the entered text. The benefit is that `SearchActionProvider` does not depend on querying a search engine first and therefore the suggestion will appear faster - especially on slow networks.
Co-authored-by: default avatarSebastian Kaspari <s.kaspari@gmail.com>
parents 862331cc ba6a466c
......@@ -6,7 +6,9 @@ package mozilla.components.feature.awesomebar
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.view.View
import kotlinx.coroutines.Deferred
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.SearchEngineManager
......@@ -20,6 +22,7 @@ import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.concept.toolbar.Toolbar
import mozilla.components.feature.awesomebar.provider.ClipboardSuggestionProvider
import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider
import mozilla.components.feature.awesomebar.provider.SearchActionProvider
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
import mozilla.components.feature.search.SearchUseCases
......@@ -72,6 +75,7 @@ class AwesomeBarFeature(
* @param mode Whether to return a single search suggestion (with chips) or one suggestion per item.
* @param engine optional [Engine] instance to call [Engine.speculativeConnect] for the
* highest scored search suggestion URL.
* @param filterExactMatch If true filters out suggestions that exactly match the entered text.
*/
@Suppress("LongParameterList")
fun addSearchProvider(
......@@ -80,9 +84,18 @@ class AwesomeBarFeature(
fetchClient: Client,
limit: Int = 15,
mode: SearchSuggestionProvider.Mode = SearchSuggestionProvider.Mode.SINGLE_SUGGESTION,
engine: Engine? = null
engine: Engine? = null,
filterExactMatch: Boolean = false
): AwesomeBarFeature {
awesomeBar.addProviders(SearchSuggestionProvider(searchEngine, searchUseCase, fetchClient, limit, mode, engine))
awesomeBar.addProviders(SearchSuggestionProvider(
searchEngine,
searchUseCase,
fetchClient,
limit,
mode,
engine,
filterExactMatch = filterExactMatch
))
return this
}
......@@ -100,6 +113,7 @@ class AwesomeBarFeature(
* @param mode Whether to return a single search suggestion (with chips) or one suggestion per item.
* @param engine optional [Engine] instance to call [Engine.speculativeConnect] for the
* highest scored search suggestion URL.
* @param filterExactMatch If true filters out suggestions that exactly match the entered text.
*/
@Suppress("LongParameterList")
fun addSearchProvider(
......@@ -109,11 +123,43 @@ class AwesomeBarFeature(
fetchClient: Client,
limit: Int = 15,
mode: SearchSuggestionProvider.Mode = SearchSuggestionProvider.Mode.SINGLE_SUGGESTION,
engine: Engine? = null
engine: Engine? = null,
filterExactMatch: Boolean = false
): AwesomeBarFeature {
awesomeBar.addProviders(SearchSuggestionProvider(
context,
searchEngineManager,
searchUseCase,
fetchClient,
limit,
mode,
engine,
filterExactMatch = filterExactMatch
))
return this
}
/**
* Adds an [AwesomeBar.SuggestionProvider] implementation that always returns a suggestion that
* mirrors the entered text and invokes a search with the given [SearchEngine] if clicked.
*
* @param searchEngine The search engine to search with.
* @param searchUseCase The use case to invoke for searches.
* @param icon The image to display next to the result. If not specified, the engine icon is used.
* @param showDescription whether or not to add the search engine name as description.
*/
fun addSearchActionProvider(
searchEngine: Deferred<SearchEngine>,
searchUseCase: SearchUseCases.SearchUseCase,
icon: Bitmap? = null,
showDescription: Boolean = false
): AwesomeBarFeature {
awesomeBar.addProviders(
SearchSuggestionProvider(context, searchEngineManager, searchUseCase, fetchClient, limit, mode, engine)
)
awesomeBar.addProviders(SearchActionProvider(
searchEngine,
searchUseCase,
icon,
showDescription
))
return this
}
......
/* 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.feature.awesomebar.provider
import android.graphics.Bitmap
import kotlinx.coroutines.Deferred
import mozilla.components.browser.search.SearchEngine
import mozilla.components.concept.awesomebar.AwesomeBar
import mozilla.components.feature.search.SearchUseCases
private const val FIXED_ID = "@@@search.action.provider.fixed.id@@"
/**
* An [AwesomeBar.SuggestionProvider] implementation that returns a suggestion that mirrors the
* entered text and invokes a search with the given [SearchEngine] if clicked.
*/
class SearchActionProvider(
private val searchEngine: Deferred<SearchEngine>,
private val searchUseCase: SearchUseCases.SearchUseCase,
private val icon: Bitmap? = null,
private val showDescription: Boolean = true
) : AwesomeBar.SuggestionProvider {
override val id: String = java.util.UUID.randomUUID().toString()
override suspend fun onInputChanged(text: String): List<AwesomeBar.Suggestion> {
if (text.isBlank()) {
return emptyList()
}
return listOf(AwesomeBar.Suggestion(
provider = this,
// We always use the same ID for the entered text so that this suggestion gets replaced "in place".
id = FIXED_ID,
title = text,
description = if (showDescription) searchEngine.await().name else null,
icon = icon ?: searchEngine.await().icon,
score = Int.MAX_VALUE,
onSuggestionClicked = {
searchUseCase.invoke(text)
}
))
}
override val shouldClearSuggestions: Boolean = false
}
......@@ -26,39 +26,21 @@ import java.util.concurrent.TimeUnit
* A [AwesomeBar.SuggestionProvider] implementation that provides a suggestion containing search engine suggestions (as
* chips) from the passed in [SearchEngine].
*/
class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
@Suppress("LongParameterList")
class SearchSuggestionProvider private constructor(
@VisibleForTesting internal val client: SearchSuggestionClient,
private val searchUseCase: SearchUseCases.SearchUseCase,
private val limit: Int = 15,
private val mode: Mode = Mode.SINGLE_SUGGESTION,
@VisibleForTesting internal val engine: Engine? = null,
private val icon: Bitmap? = null,
private val showDescription: Boolean = true,
private val filterExactMatch: Boolean = false
) : AwesomeBar.SuggestionProvider {
override val id: String = UUID.randomUUID().toString()
@VisibleForTesting
internal val client: SearchSuggestionClient
private val searchUseCase: SearchUseCases.SearchUseCase
private val limit: Int
private val mode: Mode
private val icon: Bitmap?
private val showDescription: Boolean
@VisibleForTesting
internal val engine: Engine?
private constructor(
client: SearchSuggestionClient,
searchUseCase: SearchUseCases.SearchUseCase,
limit: Int = 15,
mode: Mode = Mode.SINGLE_SUGGESTION,
engine: Engine? = null,
icon: Bitmap? = null,
showDescription: Boolean = true
) {
init {
require(limit >= 1) { "limit needs to be >= 1" }
this.client = client
this.searchUseCase = searchUseCase
this.limit = limit
this.mode = mode
this.icon = icon
this.showDescription = showDescription
this.engine = engine
}
/**
......@@ -73,6 +55,7 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
* highest scored search suggestion URL.
* @param icon The image to display next to the result. If not specified, the engine icon is used.
* @param showDescription whether or not to add the search engine name as description.
* @param filterExactMatch If true filters out suggestions that exactly match the entered text.
*/
constructor(
searchEngine: SearchEngine,
......@@ -82,7 +65,8 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
mode: Mode = Mode.SINGLE_SUGGESTION,
engine: Engine? = null,
icon: Bitmap? = null,
showDescription: Boolean = true
showDescription: Boolean = true,
filterExactMatch: Boolean = false
) : this (
SearchSuggestionClient(searchEngine) { url -> fetch(fetchClient, url) },
searchUseCase,
......@@ -90,7 +74,8 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
mode,
engine,
icon,
showDescription
showDescription,
filterExactMatch
)
/**
......@@ -107,6 +92,7 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
* highest scored search suggestion URL.
* @param icon The image to display next to the result. If not specified, the engine icon is used.
* @param showDescription whether or not to add the search engine name as description.
* @param filterExactMatch If true filters out suggestions that exactly match the entered text.
*/
constructor(
context: Context,
......@@ -117,7 +103,8 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
mode: Mode = Mode.SINGLE_SUGGESTION,
engine: Engine? = null,
icon: Bitmap? = null,
showDescription: Boolean = true
showDescription: Boolean = true,
filterExactMatch: Boolean = false
) : this (
SearchSuggestionClient(context, searchEngineManager) { url -> fetch(fetchClient, url) },
searchUseCase,
......@@ -125,7 +112,8 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
mode,
engine,
icon,
showDescription
showDescription,
filterExactMatch
)
@Suppress("ReturnCount")
......@@ -172,10 +160,14 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
val suggestions = mutableListOf<AwesomeBar.Suggestion>()
val list = (result ?: listOf(text)).toMutableList()
if (!list.contains(text)) {
if (!list.contains(text) && !filterExactMatch) {
list.add(0, text)
}
if (filterExactMatch && list.contains(text)) {
list.remove(text)
}
val description = if (showDescription) {
client.searchEngine?.name
} else {
......@@ -190,7 +182,7 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
title = item,
description = description,
icon = icon ?: client.searchEngine?.icon,
score = Int.MAX_VALUE - index,
score = Int.MAX_VALUE - (index + 1),
onSuggestionClicked = {
searchUseCase.invoke(item)
}
......@@ -200,16 +192,19 @@ class SearchSuggestionProvider : AwesomeBar.SuggestionProvider {
return suggestions
}
@Suppress("ComplexCondition")
private fun createSingleSearchSuggestion(text: String, result: List<String>?): List<AwesomeBar.Suggestion> {
val chips = mutableListOf<AwesomeBar.Suggestion.Chip>()
if (result == null || result.isEmpty() || !result.contains(text)) {
if ((result == null || result.isEmpty() || !result.contains(text)) && !filterExactMatch) {
// Add the entered text as first suggestion if needed
chips.add(AwesomeBar.Suggestion.Chip(text))
}
result?.take(limit - chips.size)?. forEach { title ->
chips.add(AwesomeBar.Suggestion.Chip(title))
if (!filterExactMatch || title != text) {
chips.add(AwesomeBar.Suggestion.Chip(title))
}
}
return listOf(AwesomeBar.Suggestion(
......
......@@ -203,6 +203,19 @@ class AwesomeBarFeatureTest {
assertSame(engine, provider.value.engine)
}
@Test
fun `addSearchActionProvider adds provider`() {
val awesomeBar: AwesomeBar = mock()
val feature = AwesomeBarFeature(awesomeBar, mock())
verify(awesomeBar, never()).addProviders(any())
feature.addSearchActionProvider(mock(), mock())
verify(awesomeBar).addProviders(any())
}
@Test
fun `Feature invokes custom start and complete hooks`() {
val toolbar: Toolbar = mock()
......
/* 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.feature.awesomebar.provider
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.search.SearchEngine
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Test
class SearchActionProviderTest {
@Test
fun `provider returns no suggestion for empty text`() {
val provider = SearchActionProvider(mock(), mock())
val suggestions = runBlocking { provider.onInputChanged("") }
assertEquals(0, suggestions.size)
}
@Test
fun `provider returns no suggestion for blank text`() {
val provider = SearchActionProvider(mock(), mock())
val suggestions = runBlocking { provider.onInputChanged(" ") }
assertEquals(0, suggestions.size)
}
@Test
fun `provider returns suggestion matching input`() {
val provider = SearchActionProvider(
searchEngine = GlobalScope.async { mock<SearchEngine>() },
searchUseCase = mock()
)
val suggestions = runBlocking { provider.onInputChanged("firefox") }
assertEquals(1, suggestions.size)
val suggestion = suggestions[0]
assertEquals("firefox", suggestion.title)
}
}
......@@ -535,4 +535,95 @@ class SearchSuggestionProviderTest {
}
}
}
@Test
fun `Provider filters exact match from multiple suggestions`() {
runBlocking {
val server = MockWebServer()
server.enqueue(MockResponse().setBody(GOOGLE_MOCK_RESPONSE_WITH_DUPLICATES))
server.start()
val searchEngine: SearchEngine = mock()
doReturn(server.url("/").toString())
.`when`(searchEngine).buildSuggestionsURL("firefox")
doReturn(true).`when`(searchEngine).canProvideSearchSuggestions
doReturn("google").`when`(searchEngine).name
val searchEngineManager: SearchEngineManager = mock()
doReturn(searchEngine).`when`(searchEngineManager).getDefaultSearchEngineAsync(any(), any())
val useCase: SearchUseCases.SearchUseCase = mock()
val provider = SearchSuggestionProvider(
searchEngine,
useCase,
HttpURLConnectionClient(),
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
filterExactMatch = true
)
try {
val suggestions = provider.onInputChanged("firefox")
assertEquals(10, suggestions.size)
assertEquals("firefox for mac", suggestions[1].title)
assertEquals("firefox quantum", suggestions[2].title)
assertEquals("firefox update", suggestions[3].title)
assertEquals("firefox esr", suggestions[4].title)
assertEquals("firefox focus", suggestions[5].title)
assertEquals("firefox addons", suggestions[6].title)
assertEquals("firefox extensions", suggestions[7].title)
assertEquals("firefox nightly", suggestions[8].title)
assertEquals("firefox clear cache", suggestions[9].title)
} finally {
server.shutdown()
}
}
}
@Test
fun `Provider filters chips with exact match`() {
runBlocking {
val server = MockWebServer()
server.enqueue(MockResponse().setBody(GOOGLE_MOCK_RESPONSE))
server.start()
val searchEngine: SearchEngine = mock()
doReturn(server.url("/").toString())
.`when`(searchEngine).buildSuggestionsURL("firefox")
doReturn(true).`when`(searchEngine).canProvideSearchSuggestions
doReturn("google").`when`(searchEngine).name
val searchEngineManager: SearchEngineManager = mock()
doReturn(searchEngine).`when`(searchEngineManager).getDefaultSearchEngineAsync(any(), any())
val useCase: SearchUseCases.SearchUseCase = mock()
val provider = SearchSuggestionProvider(
searchEngine,
useCase,
HttpURLConnectionClient(),
filterExactMatch = true
)
try {
val suggestions = provider.onInputChanged("firefox")
assertEquals(1, suggestions.size)
val suggestion = suggestions[0]
assertEquals(9, suggestion.chips.size)
assertEquals("firefox for mac", suggestion.chips[0].title)
assertEquals("firefox quantum", suggestion.chips[1].title)
assertEquals("firefox update", suggestion.chips[2].title)
assertEquals("firefox esr", suggestion.chips[3].title)
assertEquals("firefox focus", suggestion.chips[4].title)
assertEquals("firefox addons", suggestion.chips[5].title)
assertEquals("firefox extensions", suggestion.chips[6].title)
assertEquals("firefox nightly", suggestion.chips[7].title)
assertEquals("firefox clear cache", suggestion.chips[8].title)
} finally {
server.shutdown()
}
}
}
}
......@@ -9,6 +9,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.fragment_browser.view.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import mozilla.components.browser.thumbnails.BrowserThumbnails
import mozilla.components.feature.awesomebar.AwesomeBarFeature
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
......@@ -54,6 +56,8 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
TabsToolbarFeature(layout.toolbar, components.sessionManager, sessionId, ::showTabs)
val applicationContext = requireContext().applicationContext
AwesomeBarFeature(layout.awesomeBar, layout.toolbar, layout.engineView, components.icons)
.addHistoryProvider(
components.historyStorage,
......@@ -65,13 +69,20 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
components.store,
components.tabsUseCases.selectTab
)
.addSearchActionProvider(
searchEngine = GlobalScope.async {
components.searchEngineManager.getDefaultSearchEngine(applicationContext)
},
searchUseCase = components.searchUseCases.defaultSearch
)
.addSearchProvider(
requireContext(),
components.searchEngineManager,
components.searchUseCases.defaultSearch,
fetchClient = components.client,
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
engine = components.engine
engine = components.engine,
filterExactMatch = true
)
.addClipboardProvider(
requireContext(),
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment