Integrate new search code from Android Components into Fenix.

upstream-sync
Sebastian Kaspari 4 years ago
parent 2904ca8ac0
commit 2b759e9d6f

@ -38,6 +38,7 @@ import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import androidx.test.uiautomator.Until.findObject import androidx.test.uiautomator.Until.findObject
import mozilla.components.support.ktx.android.content.appName import mozilla.components.support.ktx.android.content.appName
import mozilla.components.browser.state.state.searchEngines
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.CoreMatchers.instanceOf
@ -45,7 +46,7 @@ import org.hamcrest.CoreMatchers.not
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.junit.Assert import org.junit.Assert
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.Search import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.TestAssetHelper import org.mozilla.fenix.helpers.TestAssetHelper
import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime
import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText
@ -579,10 +580,11 @@ private fun verifySearchEngineIcon(searchEngineIcon: Bitmap, searchEngineName: S
} }
private fun getSearchEngine(searchEngineName: String) = private fun getSearchEngine(searchEngineName: String) =
Search(appContext).searchEngineManager.getDefaultSearchEngine(appContext, searchEngineName) appContext.components.core.store.state.search.searchEngines.find { it.name == searchEngineName }
private fun verifySearchEngineIcon(searchEngineName: String) { private fun verifySearchEngineIcon(searchEngineName: String) {
val ddgSearchEngine = getSearchEngine(searchEngineName) val ddgSearchEngine = getSearchEngine(searchEngineName)
?: throw AssertionError("No search engine with name $searchEngineName")
verifySearchEngineIcon(ddgSearchEngine.icon, ddgSearchEngine.name) verifySearchEngineIcon(ddgSearchEngine.icon, ddgSearchEngine.name)
} }

@ -37,7 +37,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.WebExtensionState import mozilla.components.browser.state.state.WebExtensionState
@ -46,6 +46,7 @@ import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.contextmenu.DefaultSelectionActionDelegate import mozilla.components.feature.contextmenu.DefaultSelectionActionDelegate
import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature
import mozilla.components.feature.search.BrowserStoreSearchAdapter import mozilla.components.feature.search.BrowserStoreSearchAdapter
import mozilla.components.feature.search.ext.legacy
import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.android.arch.lifecycle.addObservers import mozilla.components.support.ktx.android.arch.lifecycle.addObservers
@ -139,7 +140,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
private val externalSourceIntentProcessors by lazy { private val externalSourceIntentProcessors by lazy {
listOf( listOf(
SpeechProcessingIntentProcessor(this, components.analytics.metrics), SpeechProcessingIntentProcessor(this, components.core.store, components.analytics.metrics),
StartSearchIntentProcessor(components.analytics.metrics), StartSearchIntentProcessor(components.analytics.metrics),
DeepLinkIntentProcessor(this, components.analytics.leanplumMetricsService), DeepLinkIntentProcessor(this, components.analytics.leanplumMetricsService),
OpenBrowserIntentProcessor(this, ::getIntentSessionId), OpenBrowserIntentProcessor(this, ::getIntentSessionId),
@ -737,23 +738,24 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
} }
} else components.useCases.sessionUseCases.loadUrl } else components.useCases.sessionUseCases.loadUrl
val searchUseCase: (String) -> Unit = { searchTerms -> // In situations where we want to perform a search but have no search engine (e.g. the user
// has removed all of them, or we couldn't load any) we will pass searchTermOrURL to Gecko
// and let it try to load whatever was entered.
if ((!forceSearch && searchTermOrURL.isUrl()) || engine == null) {
loadUrlUseCase.invoke(searchTermOrURL.toNormalizedUrl(), flags)
} else {
if (newTab) { if (newTab) {
components.useCases.searchUseCases.newTabSearch components.useCases.searchUseCases.newTabSearch
.invoke( .invoke(
searchTerms, searchTermOrURL,
SessionState.Source.USER_ENTERED, SessionState.Source.USER_ENTERED,
true, true,
mode.isPrivate, mode.isPrivate,
searchEngine = engine searchEngine = engine.legacy()
) )
} else components.useCases.searchUseCases.defaultSearch.invoke(searchTerms, engine) } else {
} components.useCases.searchUseCases.defaultSearch.invoke(searchTermOrURL, engine.legacy())
}
if (!forceSearch && searchTermOrURL.isUrl()) {
loadUrlUseCase.invoke(searchTermOrURL.toNormalizedUrl(), flags)
} else {
searchUseCase.invoke(searchTermOrURL)
} }
if (components.core.engine.profiler?.isProfilerActive() == true) { if (components.core.engine.profiler?.isProfilerActive() == true) {

@ -24,6 +24,7 @@ import org.mozilla.fenix.components.metrics.AdjustMetricsService
import org.mozilla.fenix.components.metrics.GleanMetricsService import org.mozilla.fenix.components.metrics.GleanMetricsService
import org.mozilla.fenix.components.metrics.LeanplumMetricsService import org.mozilla.fenix.components.metrics.LeanplumMetricsService
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.fenix.utils.Mockable import org.mozilla.fenix.utils.Mockable
@ -92,7 +93,7 @@ class Analytics(
val metrics: MetricController by lazyMonitored { val metrics: MetricController by lazyMonitored {
MetricController.create( MetricController.create(
listOf( listOf(
GleanMetricsService(context), GleanMetricsService(context, lazy { context.components.core.store }),
leanplumMetricsService, leanplumMetricsService,
AdjustMetricsService(context as Application) AdjustMetricsService(context as Application)
), ),

@ -54,14 +54,12 @@ class Components(private val context: Context) {
} }
val services by lazyMonitored { Services(context, backgroundServices.accountManager) } val services by lazyMonitored { Services(context, backgroundServices.accountManager) }
val core by lazyMonitored { Core(context, analytics.crashReporter, strictMode) } val core by lazyMonitored { Core(context, analytics.crashReporter, strictMode) }
val search by lazyMonitored { Search(context) }
val useCases by lazyMonitored { val useCases by lazyMonitored {
UseCases( UseCases(
context, context,
core.engine, core.engine,
core.sessionManager, core.sessionManager,
core.store, core.store,
search.searchEngineManager,
core.webAppShortcutManager, core.webAppShortcutManager,
core.topSitesStorage core.topSitesStorage
) )

@ -46,6 +46,8 @@ import mozilla.components.feature.pwa.ManifestStorage
import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.pwa.WebAppShortcutManager
import mozilla.components.feature.readerview.ReaderViewMiddleware import mozilla.components.feature.readerview.ReaderViewMiddleware
import mozilla.components.feature.recentlyclosed.RecentlyClosedMiddleware import mozilla.components.feature.recentlyclosed.RecentlyClosedMiddleware
import mozilla.components.feature.search.middleware.SearchMiddleware
import mozilla.components.feature.search.region.RegionMiddleware
import mozilla.components.feature.session.HistoryDelegate import mozilla.components.feature.session.HistoryDelegate
import mozilla.components.feature.top.sites.DefaultTopSitesStorage import mozilla.components.feature.top.sites.DefaultTopSitesStorage
import mozilla.components.feature.top.sites.PinnedSiteStorage import mozilla.components.feature.top.sites.PinnedSiteStorage
@ -57,14 +59,18 @@ import mozilla.components.lib.dataprotect.generateEncryptionKey
import mozilla.components.service.digitalassetlinks.RelationChecker import mozilla.components.service.digitalassetlinks.RelationChecker
import mozilla.components.service.digitalassetlinks.local.StatementApi import mozilla.components.service.digitalassetlinks.local.StatementApi
import mozilla.components.service.digitalassetlinks.local.StatementRelationChecker import mozilla.components.service.digitalassetlinks.local.StatementRelationChecker
import mozilla.components.service.location.LocationService
import mozilla.components.service.location.MozillaLocationService
import mozilla.components.service.sync.logins.SyncableLoginsStorage import mozilla.components.service.sync.logins.SyncableLoginsStorage
import mozilla.components.support.locale.LocaleManager import mozilla.components.support.locale.LocaleManager
import org.mozilla.fenix.AppRequestInterceptor import org.mozilla.fenix.AppRequestInterceptor
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.Config import org.mozilla.fenix.Config
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.perf.StrictModeManager import org.mozilla.fenix.perf.StrictModeManager
import org.mozilla.fenix.TelemetryMiddleware import org.mozilla.fenix.TelemetryMiddleware
import org.mozilla.fenix.components.search.SearchMigration
import org.mozilla.fenix.downloads.DownloadService import org.mozilla.fenix.downloads.DownloadService
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
@ -153,6 +159,14 @@ class Core(
SessionStorage(context, engine = engine) SessionStorage(context, engine = engine)
} }
private val locationService: LocationService by lazyMonitored {
if (Config.channel.isDebug || BuildConfig.MLS_TOKEN.isEmpty()) {
LocationService.default()
} else {
MozillaLocationService(context, client, BuildConfig.MLS_TOKEN)
}
}
/** /**
* The [BrowserStore] holds the global [BrowserState]. * The [BrowserStore] holds the global [BrowserState].
*/ */
@ -169,7 +183,13 @@ class Core(
metrics metrics
), ),
ThumbnailsMiddleware(thumbnailStorage), ThumbnailsMiddleware(thumbnailStorage),
UndoMiddleware(::lookupSessionManager, context.getUndoDelay()) UndoMiddleware(::lookupSessionManager, context.getUndoDelay()),
RegionMiddleware(context, locationService),
SearchMiddleware(
context,
additionalBundledSearchEngineIds = listOf("reddit", "youtube"),
migration = SearchMigration(context)
)
) + EngineMiddleware.create(engine, ::findSessionById) ) + EngineMiddleware.create(engine, ::findSessionById)
).also { ).also {
it.dispatch(RecentlyClosedAction.InitializeRecentlyClosedState) it.dispatch(RecentlyClosedAction.InitializeRecentlyClosedState)

@ -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)
}
}
}
}

@ -5,7 +5,6 @@
package org.mozilla.fenix.components package org.mozilla.fenix.components
import android.content.Context import android.content.Context
import mozilla.components.browser.search.SearchEngineManager
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.Engine
@ -15,7 +14,7 @@ import mozilla.components.feature.downloads.DownloadsUseCases
import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.pwa.WebAppShortcutManager
import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.pwa.WebAppUseCases
import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.search.SearchUseCases
import mozilla.components.browser.search.ext.toDefaultSearchEngineProvider import mozilla.components.feature.search.ext.toDefaultSearchEngineProvider
import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.session.SettingsUseCases import mozilla.components.feature.session.SettingsUseCases
import mozilla.components.feature.session.TrackingProtectionUseCases import mozilla.components.feature.session.TrackingProtectionUseCases
@ -36,7 +35,6 @@ class UseCases(
private val engine: Engine, private val engine: Engine,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val store: BrowserStore, private val store: BrowserStore,
private val searchEngineManager: SearchEngineManager,
private val shortcutManager: WebAppShortcutManager, private val shortcutManager: WebAppShortcutManager,
private val topSitesStorage: TopSitesStorage private val topSitesStorage: TopSitesStorage
) { ) {
@ -56,7 +54,7 @@ class UseCases(
val searchUseCases by lazyMonitored { val searchUseCases by lazyMonitored {
SearchUseCases( SearchUseCases(
store, store,
searchEngineManager.toDefaultSearchEngineProvider(context), store.toDefaultSearchEngineProvider(),
sessionManager sessionManager
) )
} }

@ -6,7 +6,7 @@ package org.mozilla.fenix.components.metrics
import android.content.Context import android.content.Context
import mozilla.components.browser.errorpages.ErrorType import mozilla.components.browser.errorpages.ErrorType
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.GleanMetrics.Addons import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.GleanMetrics.AppTheme import org.mozilla.fenix.GleanMetrics.AppTheme
@ -404,7 +404,7 @@ sealed class Event {
// https://github.com/mozilla-mobile/fenix/issues/1607 // https://github.com/mozilla-mobile/fenix/issues/1607
// Sanitize identifiers for custom search engines. // Sanitize identifiers for custom search engines.
val identifier: String val identifier: String
get() = if (isCustom) "custom" else engine.identifier get() = if (isCustom) "custom" else engine.id
val searchEngine: SearchEngine val searchEngine: SearchEngine
get() = when (this) { get() = when (this) {

@ -5,6 +5,9 @@
package org.mozilla.fenix.components.metrics package org.mozilla.fenix.components.metrics
import android.content.Context import android.content.Context
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.search.ext.legacy
import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine
import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.fxa.manager.SyncEnginesStorage
import mozilla.components.service.glean.Glean import mozilla.components.service.glean.Glean
import mozilla.components.service.glean.private.NoExtraKeys import mozilla.components.service.glean.private.NoExtraKeys
@ -682,6 +685,7 @@ private val Event.wrapper: EventWrapper<*>?
class GleanMetricsService( class GleanMetricsService(
private val context: Context, private val context: Context,
private val store: Lazy<BrowserStore>,
private val browsersCache: BrowsersCache = BrowsersCache, private val browsersCache: BrowsersCache = BrowsersCache,
private val mozillaProductDetector: MozillaProductDetector = MozillaProductDetector private val mozillaProductDetector: MozillaProductDetector = MozillaProductDetector
) : MetricsService { ) : MetricsService {
@ -756,20 +760,18 @@ class GleanMetricsService(
closeTabSetting.set(context.settings().getTabTimeoutPingString()) closeTabSetting.set(context.settings().getTabTimeoutPingString())
} }
SearchDefaultEngine.apply { store.value.waitForSelectedOrDefaultSearchEngine { searchEngine ->
val defaultEngine = context if (searchEngine != null) {
.components SearchDefaultEngine.apply {
.search code.set(searchEngine.id)
.searchEngineManager name.set(searchEngine.name)
.defaultSearchEngine ?: return@apply submissionUrl.set(searchEngine.legacy().buildSearchUrl(""))
}
}
code.set(defaultEngine.identifier) activationPing.checkAndSend()
name.set(defaultEngine.name) installationPing.checkAndSend()
submissionUrl.set(defaultEngine.buildSearchUrl(""))
} }
activationPing.checkAndSend()
installationPing.checkAndSend()
} }
private fun setPreferenceMetrics() { private fun setPreferenceMetrics() {

@ -12,11 +12,12 @@ import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException import com.google.android.gms.common.GooglePlayServicesRepairableException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.searchEngineManager
import java.io.IOException import java.io.IOException
import java.security.NoSuchAlgorithmException import java.security.NoSuchAlgorithmException
import java.security.spec.InvalidKeySpecException import java.security.spec.InvalidKeySpecException
@ -26,11 +27,11 @@ import javax.crypto.spec.PBEKeySpec
object MetricsUtils { object MetricsUtils {
fun createSearchEvent( fun createSearchEvent(
engine: SearchEngine, engine: SearchEngine,
context: Context, store: BrowserStore,
searchAccessPoint: SearchAccessPoint searchAccessPoint: SearchAccessPoint
): Event.PerformedSearch? { ): Event.PerformedSearch? {
val isShortcut = engine != context.searchEngineManager.defaultSearchEngine val isShortcut = engine != store.state.search.selectedOrDefaultSearchEngine
val isCustom = CustomSearchEngineStore.isCustomSearchEngine(context, engine.identifier) val isCustom = engine.type == SearchEngine.Type.CUSTOM
val engineSource = val engineSource =
if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine, isCustom) if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine, isCustom)

@ -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()
}
}
}

@ -14,7 +14,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import androidx.annotation.StringRes import androidx.annotation.StringRes
import mozilla.components.browser.search.SearchEngineManager
import mozilla.components.support.locale.LocaleManager import mozilla.components.support.locale.LocaleManager
import org.mozilla.fenix.FenixApplication import org.mozilla.fenix.FenixApplication
import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.Components
@ -41,12 +40,6 @@ val Context.components: Components
val Context.metrics: MetricController val Context.metrics: MetricController
get() = this.components.analytics.metrics get() = this.components.analytics.metrics
/**
* Helper function to get the SearchEngineManager off of context.
*/
val Context.searchEngineManager: SearchEngineManager
get() = this.components.search.searchEngineManager
fun Context.asActivity() = (this as? ContextThemeWrapper)?.baseContext as? Activity fun Context.asActivity() = (this as? ContextThemeWrapper)?.baseContext as? Activity
?: this as? Activity ?: this as? Activity

@ -40,15 +40,26 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.privateBrowsingButton
import kotlinx.android.synthetic.main.fragment_home.view.* import kotlinx.android.synthetic.main.fragment_home.search_engine_icon
import kotlinx.android.synthetic.main.no_collections_message.view.* import kotlinx.android.synthetic.main.fragment_home.toolbarLayout
import kotlinx.android.synthetic.main.fragment_home.view.bottomBarShadow
import kotlinx.android.synthetic.main.fragment_home.view.bottom_bar
import kotlinx.android.synthetic.main.fragment_home.view.homeAppBar
import kotlinx.android.synthetic.main.fragment_home.view.menuButton
import kotlinx.android.synthetic.main.fragment_home.view.sessionControlRecyclerView
import kotlinx.android.synthetic.main.fragment_home.view.tab_button
import kotlinx.android.synthetic.main.fragment_home.view.toolbar
import kotlinx.android.synthetic.main.fragment_home.view.toolbarLayout
import kotlinx.android.synthetic.main.fragment_home.view.toolbar_wrapper
import kotlinx.android.synthetic.main.no_collections_message.view.add_tabs_to_collections_button
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.appservices.places.BookmarkRoot import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.menu.view.MenuButton import mozilla.components.browser.menu.view.MenuButton
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
@ -58,6 +69,7 @@ import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.AuthType
@ -65,9 +77,11 @@ import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSitesConfig import mozilla.components.feature.top.sites.TopSitesConfig
import mozilla.components.feature.top.sites.TopSitesFeature import mozilla.components.feature.top.sites.TopSitesFeature
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.content.res.resolveAttribute import mozilla.components.support.ktx.android.content.res.resolveAttribute
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
@ -162,6 +176,7 @@ class HomeFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
postponeEnterTransition() postponeEnterTransition()
bundleArgs = args.toBundle() bundleArgs = args.toBundle()
lifecycleScope.launch(IO) { lifecycleScope.launch(IO) {
@ -227,6 +242,7 @@ class HomeFragment : Fragment() {
settings = components.settings, settings = components.settings,
engine = components.core.engine, engine = components.core.engine,
metrics = components.analytics.metrics, metrics = components.analytics.metrics,
store = store,
sessionManager = sessionManager, sessionManager = sessionManager,
tabCollectionStorage = components.core.tabCollectionStorage, tabCollectionStorage = components.core.tabCollectionStorage,
addTabUseCase = components.useCases.tabsUseCases.addTab, addTabUseCase = components.useCases.tabsUseCases.addTab,
@ -338,23 +354,7 @@ class HomeFragment : Fragment() {
delay(ANIMATION_DELAY) delay(ANIMATION_DELAY)
} }
viewLifecycleOwner.lifecycleScope.launch(IO) { observeSearchEngineChanges()
// This is necessary due to a bug in viewLifecycleOwner. See:
// https://github.com/mozilla-mobile/android-components/blob/master/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt#L32-L56
// TODO remove when viewLifecycleOwner is fixed
val context = context ?: return@launch
val iconSize =
context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
val searchEngine = context.components.search.provider.getDefaultEngine(context)
val searchIcon = BitmapDrawable(context.resources, searchEngine.icon)
searchIcon.setBounds(0, 0, iconSize, iconSize)
withContext(Main) {
search_engine_icon?.setImageDrawable(searchIcon)
}
}
createHomeMenu(requireContext(), WeakReference(view.menuButton)) createHomeMenu(requireContext(), WeakReference(view.menuButton))
val tabCounterMenu = TabCounterMenu( val tabCounterMenu = TabCounterMenu(
@ -446,6 +446,24 @@ class HomeFragment : Fragment() {
} }
} }
private fun observeSearchEngineChanges() {
consumeFlow(store) { flow ->
flow.map { state -> state.search.selectedOrDefaultSearchEngine }
.ifChanged()
.collect { searchEngine ->
if (searchEngine != null) {
val iconSize =
requireContext().resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
val searchIcon = BitmapDrawable(requireContext().resources, searchEngine.icon)
searchIcon.setBounds(0, 0, iconSize, iconSize)
search_engine_icon?.setImageDrawable(searchIcon)
} else {
search_engine_icon.setImageDrawable(null)
}
}
}
}
private fun removeAllTabsAndShowSnackbar(sessionCode: String) { private fun removeAllTabsAndShowSnackbar(sessionCode: String) {
if (sessionCode == ALL_PRIVATE_TABS) { if (sessionCode == ALL_PRIVATE_TABS) {
sessionManager.removePrivateSessions() sessionManager.removePrivateSessions()
@ -499,6 +517,7 @@ class HomeFragment : Fragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_sessionControlInteractor = null _sessionControlInteractor = null
sessionControlView = null sessionControlView = null
bundleArgs.clear() bundleArgs.clear()
@ -507,6 +526,7 @@ class HomeFragment : Fragment() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
subscribeToTabCollections() subscribeToTabCollections()
val context = requireContext() val context = requireContext()

@ -7,6 +7,9 @@ package org.mozilla.fenix.home.intent
import android.content.Intent import android.content.Intent
import android.os.StrictMode import android.os.StrictMode
import androidx.navigation.NavController import androidx.navigation.NavController
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
@ -21,30 +24,48 @@ import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING
*/ */
class SpeechProcessingIntentProcessor( class SpeechProcessingIntentProcessor(
private val activity: HomeActivity, private val activity: HomeActivity,
private val store: BrowserStore,
private val metrics: MetricController private val metrics: MetricController
) : HomeIntentProcessor { ) : HomeIntentProcessor {
override fun process(intent: Intent, navController: NavController, out: Intent): Boolean { override fun process(intent: Intent, navController: NavController, out: Intent): Boolean {
return if (intent.extras?.getBoolean(HomeActivity.OPEN_TO_BROWSER_AND_LOAD) == true) { if (
out.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false) !intent.hasExtra(SPEECH_PROCESSING) ||
activity.components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { intent.extras?.getBoolean(HomeActivity.OPEN_TO_BROWSER_AND_LOAD) != true
val searchEvent = MetricsUtils.createSearchEvent( ) {
activity.components.search.provider.getDefaultEngine(activity), return false
activity, }
Event.PerformedSearch.SearchAccessPoint.WIDGET
out.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false)
store.waitForSelectedOrDefaultSearchEngine { searchEngine ->
if (searchEngine != null) {
launchToBrowser(
searchEngine,
intent.getStringExtra(SPEECH_PROCESSING).orEmpty()
) )
searchEvent?.let { metrics.track(it) }
} }
}
activity.openToBrowserAndLoad( return true
searchTermOrURL = intent.getStringExtra(SPEECH_PROCESSING).orEmpty(), }
newTab = true,
from = BrowserDirection.FromGlobal, private fun launchToBrowser(searchEngine: SearchEngine, text: String) {
forceSearch = true activity.components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
val searchEvent = MetricsUtils.createSearchEvent(
searchEngine,
store,
Event.PerformedSearch.SearchAccessPoint.WIDGET
) )
true searchEvent?.let { metrics.track(it) }
} else {
false
} }
activity.openToBrowserAndLoad(
searchTermOrURL = text,
newTab = true,
from = BrowserDirection.FromGlobal,
engine = searchEngine,
forceSearch = true
)
} }
} }

@ -12,6 +12,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
@ -171,6 +173,7 @@ class DefaultSessionControlController(
private val engine: Engine, private val engine: Engine,
private val metrics: MetricController, private val metrics: MetricController,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val store: BrowserStore,
private val tabCollectionStorage: TabCollectionStorage, private val tabCollectionStorage: TabCollectionStorage,
private val addTabUseCase: TabsUseCases.AddNewTabUseCase, private val addTabUseCase: TabsUseCases.AddNewTabUseCase,
private val fragmentStore: HomeFragmentStore, private val fragmentStore: HomeFragmentStore,
@ -462,21 +465,23 @@ class DefaultSessionControlController(
} }
override fun handlePasteAndGo(clipboardText: String) { override fun handlePasteAndGo(clipboardText: String) {
val searchEngine = store.state.search.selectedOrDefaultSearchEngine
activity.openToBrowserAndLoad( activity.openToBrowserAndLoad(
searchTermOrURL = clipboardText, searchTermOrURL = clipboardText,
newTab = true, newTab = true,
from = BrowserDirection.FromHome, from = BrowserDirection.FromHome,
engine = activity.components.search.provider.getDefaultEngine(activity) engine = searchEngine
) )
val event = if (clipboardText.isUrl()) { val event = if (clipboardText.isUrl() || searchEngine == null) {
Event.EnteredUrl(false) Event.EnteredUrl(false)
} else { } else {
val searchAccessPoint = Event.PerformedSearch.SearchAccessPoint.ACTION val searchAccessPoint = Event.PerformedSearch.SearchAccessPoint.ACTION
searchAccessPoint.let { sap -> searchAccessPoint.let { sap ->
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
activity.components.search.provider.getDefaultEngine(activity), searchEngine,
activity, store,
sap sap
) )
} }

@ -12,9 +12,10 @@ import android.text.SpannableString
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.navigation.NavController import androidx.navigation.NavController
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.ktx.kotlin.isUrl import mozilla.components.support.ktx.kotlin.isUrl
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
@ -22,7 +23,6 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.components.metrics.MetricsUtils
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.crashes.CrashListActivity import org.mozilla.fenix.crashes.CrashListActivity
import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
@ -50,7 +50,8 @@ interface SearchController {
class SearchDialogController( class SearchDialogController(
private val activity: HomeActivity, private val activity: HomeActivity,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val store: SearchFragmentStore, private val store: BrowserStore,
private val fragmentStore: SearchFragmentStore,
private val navController: NavController, private val navController: NavController,
private val settings: Settings, private val settings: Settings,
private val metrics: MetricController, private val metrics: MetricController,
@ -81,25 +82,27 @@ class SearchDialogController(
} }
private fun openSearchOrUrl(url: String) { private fun openSearchOrUrl(url: String) {
val searchEngine = fragmentStore.state.searchEngineSource.searchEngine
activity.openToBrowserAndLoad( activity.openToBrowserAndLoad(
searchTermOrURL = url, searchTermOrURL = url,
newTab = store.state.tabId == null, newTab = fragmentStore.state.tabId == null,
from = BrowserDirection.FromSearchDialog, from = BrowserDirection.FromSearchDialog,
engine = store.state.searchEngineSource.searchEngine engine = searchEngine
) )
val event = if (url.isUrl()) { val event = if (url.isUrl() || searchEngine == null) {
Event.EnteredUrl(false) Event.EnteredUrl(false)
} else { } else {
val searchAccessPoint = when (store.state.searchAccessPoint) { val searchAccessPoint = when (fragmentStore.state.searchAccessPoint) {
Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.ACTION Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.ACTION
else -> store.state.searchAccessPoint else -> fragmentStore.state.searchAccessPoint
} }
searchAccessPoint?.let { sap -> searchAccessPoint?.let { sap ->
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
store.state.searchEngineSource.searchEngine, searchEngine,
activity, store,
sap sap
) )
} }
@ -114,17 +117,17 @@ class SearchDialogController(
override fun handleTextChanged(text: String) { override fun handleTextChanged(text: String) {
// Display the search shortcuts on each entry of the search fragment (see #5308) // Display the search shortcuts on each entry of the search fragment (see #5308)
val textMatchesCurrentUrl = store.state.url == text val textMatchesCurrentUrl = fragmentStore.state.url == text
val textMatchesCurrentSearch = store.state.searchTerms == text val textMatchesCurrentSearch = fragmentStore.state.searchTerms == text
store.dispatch(SearchFragmentAction.UpdateQuery(text)) fragmentStore.dispatch(SearchFragmentAction.UpdateQuery(text))
store.dispatch( fragmentStore.dispatch(
SearchFragmentAction.ShowSearchShortcutEnginePicker( SearchFragmentAction.ShowSearchShortcutEnginePicker(
(textMatchesCurrentUrl || textMatchesCurrentSearch || text.isEmpty()) && (textMatchesCurrentUrl || textMatchesCurrentSearch || text.isEmpty()) &&
settings.shouldShowSearchShortcuts settings.shouldShowSearchShortcuts
) )
) )
store.dispatch( fragmentStore.dispatch(
SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt( SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(
text.isNotEmpty() && text.isNotEmpty() &&
activity.browsingModeManager.mode.isPrivate && activity.browsingModeManager.mode.isPrivate &&
@ -139,7 +142,7 @@ class SearchDialogController(
activity.openToBrowserAndLoad( activity.openToBrowserAndLoad(
searchTermOrURL = url, searchTermOrURL = url,
newTab = store.state.tabId == null, newTab = fragmentStore.state.tabId == null,
from = BrowserDirection.FromSearchDialog from = BrowserDirection.FromSearchDialog
) )
@ -149,39 +152,41 @@ class SearchDialogController(
override fun handleSearchTermsTapped(searchTerms: String) { override fun handleSearchTermsTapped(searchTerms: String) {
clearToolbarFocus() clearToolbarFocus()
val searchEngine = fragmentStore.state.searchEngineSource.searchEngine
activity.openToBrowserAndLoad( activity.openToBrowserAndLoad(
searchTermOrURL = searchTerms, searchTermOrURL = searchTerms,
newTab = store.state.tabId == null, newTab = fragmentStore.state.tabId == null,
from = BrowserDirection.FromSearchDialog, from = BrowserDirection.FromSearchDialog,
engine = store.state.searchEngineSource.searchEngine, engine = searchEngine,
forceSearch = true forceSearch = true
) )
val searchAccessPoint = when (store.state.searchAccessPoint) { val searchAccessPoint = when (fragmentStore.state.searchAccessPoint) {
Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.SUGGESTION Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.SUGGESTION
else -> store.state.searchAccessPoint else -> fragmentStore.state.searchAccessPoint
} }
val event = searchAccessPoint?.let { sap -> if (searchAccessPoint != null && searchEngine != null) {
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
store.state.searchEngineSource.searchEngine, searchEngine,
activity, store,
sap searchAccessPoint
) )?.apply {
metrics.track(this)
}
} }
event?.let { metrics.track(it) }
} }
override fun handleSearchShortcutEngineSelected(searchEngine: SearchEngine) { override fun handleSearchShortcutEngineSelected(searchEngine: SearchEngine) {
store.dispatch(SearchFragmentAction.SearchShortcutEngineSelected(searchEngine)) fragmentStore.dispatch(SearchFragmentAction.SearchShortcutEngineSelected(searchEngine))
val isCustom = val isCustom = searchEngine.type == SearchEngine.Type.CUSTOM
CustomSearchEngineStore.isCustomSearchEngine(activity, searchEngine.identifier)
metrics.track(Event.SearchShortcutSelected(searchEngine, isCustom)) metrics.track(Event.SearchShortcutSelected(searchEngine, isCustom))
} }
override fun handleSearchShortcutsButtonClicked() { override fun handleSearchShortcutsButtonClicked() {
val isOpen = store.state.showSearchShortcuts val isOpen = fragmentStore.state.showSearchShortcuts
store.dispatch(SearchFragmentAction.ShowSearchShortcutEnginePicker(!isOpen)) fragmentStore.dispatch(SearchFragmentAction.ShowSearchShortcutEnginePicker(!isOpen))
} }
override fun handleClickSearchEngineSettings() { override fun handleClickSearchEngineSettings() {

@ -15,7 +15,6 @@ import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.StrictMode
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.LayoutInflater import android.view.LayoutInflater
@ -41,9 +40,12 @@ import kotlinx.android.synthetic.main.fragment_search_dialog.view.*
import kotlinx.android.synthetic.main.search_suggestions_hint.view.* import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.concept.storage.HistoryStorage import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.feature.qr.QrFeature import mozilla.components.feature.qr.QrFeature
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
@ -52,13 +54,12 @@ import mozilla.components.support.ktx.android.content.hasCamera
import mozilla.components.support.ktx.android.content.isPermissionGranted import mozilla.components.support.ktx.android.content.isPermissionGranted
import mozilla.components.support.ktx.android.content.res.getSpanned import mozilla.components.support.ktx.android.content.res.getSpanned
import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import mozilla.components.ui.autocomplete.InlineAutocompleteEditText import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.isKeyboardVisible import org.mozilla.fenix.ext.isKeyboardVisible
@ -67,7 +68,6 @@ import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.search.awesomebar.AwesomeBarView import org.mozilla.fenix.search.awesomebar.AwesomeBarView
import org.mozilla.fenix.search.toolbar.ToolbarView import org.mozilla.fenix.search.toolbar.ToolbarView
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener
import org.mozilla.fenix.widget.VoiceSearchActivity import org.mozilla.fenix.widget.VoiceSearchActivity
typealias SearchDialogFragmentStore = SearchFragmentStore typealias SearchDialogFragmentStore = SearchFragmentStore
@ -143,7 +143,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
SearchDialogController( SearchDialogController(
activity = activity, activity = activity,
sessionManager = requireComponents.core.sessionManager, sessionManager = requireComponents.core.sessionManager,
store = store, store = requireComponents.core.store,
fragmentStore = store,
navController = findNavController(), navController = findNavController(),
settings = requireContext().settings(), settings = requireContext().settings(),
metrics = requireComponents.analytics.metrics, metrics = requireComponents.analytics.metrics,
@ -170,9 +171,6 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
view.awesome_bar view.awesome_bar
) )
setShortcutsChangedListener(CustomSearchEngineStore.PREF_FILE_SEARCH_ENGINES)
setShortcutsChangedListener(FenixSearchEngineProvider.PREF_FILE_SEARCH_ENGINES)
view.awesome_bar.setOnTouchListener { _, _ -> view.awesome_bar.setOnTouchListener { _, _ ->
view.hideKeyboardAndSave() view.hideKeyboardAndSave()
false false
@ -203,6 +201,14 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
consumeFlow(requireComponents.core.store) { flow ->
flow.map { state -> state.search }
.ifChanged()
.collect { search ->
store.dispatch(SearchFragmentAction.UpdateSearchState(search))
}
}
setupConstraints(view) setupConstraints(view)
// When displayed above browser, dismisses dialog on clicking scrim area // When displayed above browser, dismisses dialog on clicking scrim area
@ -475,12 +481,14 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
} }
private fun addSearchButton(toolbarView: ToolbarView) { private fun addSearchButton(toolbarView: ToolbarView) {
val searchEngine = store.state.searchEngineSource.searchEngine
toolbarView.view.addEditAction( toolbarView.view.addEditAction(
BrowserToolbar.Button( BrowserToolbar.Button(
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!, AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!,
requireContext().getString(R.string.voice_search_content_description), requireContext().getString(R.string.voice_search_content_description),
visible = { visible = {
store.state.searchEngineSource.searchEngine.identifier.contains("google") && searchEngine?.id?.contains("google") == true &&
isSpeechAvailable() && isSpeechAvailable() &&
requireContext().settings().shouldShowVoiceSearch requireContext().settings().shouldShowVoiceSearch
}, },
@ -515,17 +523,6 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null
private fun setShortcutsChangedListener(preferenceFileName: String) {
requireComponents.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
requireContext().getSharedPreferences(
preferenceFileName,
Context.MODE_PRIVATE
).registerOnSharedPreferenceChangeListener(viewLifecycleOwner) { _, _ ->
awesomeBarView.update(store.state)
}
}
}
private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) { private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) {
val shouldShowView = searchState.showClipboardSuggestions && val shouldShowView = searchState.showClipboardSuggestions &&
searchState.query.isEmpty() && searchState.query.isEmpty() &&
@ -548,8 +545,11 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private fun updateToolbarContentDescription(searchState: SearchFragmentState) { private fun updateToolbarContentDescription(searchState: SearchFragmentState) {
val urlView = toolbarView.view val urlView = toolbarView.view
.findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view) .findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
toolbarView.view.contentDescription =
searchState.searchEngineSource.searchEngine.name + ", " + urlView.hint searchState.searchEngineSource.searchEngine?.let { engine ->
toolbarView.view.contentDescription = engine.name + ", " + urlView.hint
}
urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
} }

@ -4,8 +4,8 @@
package org.mozilla.fenix.search package org.mozilla.fenix.search
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.search.SearchEngine
import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor
import org.mozilla.fenix.search.toolbar.ToolbarInteractor import org.mozilla.fenix.search.toolbar.ToolbarInteractor

@ -4,8 +4,11 @@
package org.mozilla.fenix.search package org.mozilla.fenix.search
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.lib.state.Action import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store import mozilla.components.lib.state.Store
@ -13,7 +16,6 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.search.ext.areShortcutsAvailable
/** /**
* The [Store] for holding the [SearchFragmentState] and applying [SearchFragmentAction]s. * The [Store] for holding the [SearchFragmentState] and applying [SearchFragmentAction]s.
@ -29,7 +31,11 @@ class SearchFragmentStore(
* Wraps a `SearchEngine` to give consumers the context that it was selected as a shortcut * Wraps a `SearchEngine` to give consumers the context that it was selected as a shortcut
*/ */
sealed class SearchEngineSource { sealed class SearchEngineSource {
abstract val searchEngine: SearchEngine abstract val searchEngine: SearchEngine?
object None : SearchEngineSource() {
override val searchEngine: SearchEngine? = null
}
data class Default(override val searchEngine: SearchEngine) : SearchEngineSource() data class Default(override val searchEngine: SearchEngine) : SearchEngineSource()
data class Shortcut(override val searchEngine: SearchEngine) : SearchEngineSource() data class Shortcut(override val searchEngine: SearchEngine) : SearchEngineSource()
@ -37,17 +43,20 @@ sealed class SearchEngineSource {
/** /**
* The state for the Search Screen * The state for the Search Screen
*
* @property query The current search query string * @property query The current search query string
* @property url The current URL of the tab (if this fragment is shown for an already existing tab) * @property url The current URL of the tab (if this fragment is shown for an already existing tab)
* @property searchTerms The search terms used to search previously in this tab (if this fragment is shown * @property searchTerms The search terms used to search previously in this tab (if this fragment is shown
* for an already existing tab) * for an already existing tab)
* @property searchEngineSource The current selected search engine with the context of how it was selected * @property searchEngineSource The current selected search engine with the context of how it was selected
* @property defaultEngineSource The current default search engine source * @property defaultEngine The current default search engine (or null if none is available yet)
* @property showSearchSuggestions Whether or not to show search suggestions from the search engine in the AwesomeBar * @property showSearchSuggestions Whether or not to show search suggestions from the search engine in the AwesomeBar
* @property showSearchSuggestionsHint Whether or not to show search suggestions in private hint panel * @property showSearchSuggestionsHint Whether or not to show search suggestions in private hint panel
* @property showSearchShortcuts Whether or not to show search shortcuts in the AwesomeBar * @property showSearchShortcuts Whether or not to show search shortcuts in the AwesomeBar
* @property areShortcutsAvailable Whether or not there are >=2 search engines installed * @property areShortcutsAvailable Whether or not there are >=2 search engines installed
* so to know to present users with certain options or not. * so to know to present users with certain options or not.
* @property showSearchShortcutsSetting Whether the setting for showing search shortcuts is enabled
* or disabled.
* @property showClipboardSuggestions Whether or not to show clipboard suggestion in the AwesomeBar * @property showClipboardSuggestions Whether or not to show clipboard suggestion in the AwesomeBar
* @property showHistorySuggestions Whether or not to show history suggestions in the AwesomeBar * @property showHistorySuggestions Whether or not to show history suggestions in the AwesomeBar
* @property showBookmarkSuggestions Whether or not to show the bookmark suggestion in the AwesomeBar * @property showBookmarkSuggestions Whether or not to show the bookmark suggestion in the AwesomeBar
@ -58,11 +67,12 @@ data class SearchFragmentState(
val url: String, val url: String,
val searchTerms: String, val searchTerms: String,
val searchEngineSource: SearchEngineSource, val searchEngineSource: SearchEngineSource,
val defaultEngineSource: SearchEngineSource.Default, val defaultEngine: SearchEngine?,
val showSearchSuggestions: Boolean, val showSearchSuggestions: Boolean,
val showSearchSuggestionsHint: Boolean, val showSearchSuggestionsHint: Boolean,
val showSearchShortcuts: Boolean, val showSearchShortcuts: Boolean,
val areShortcutsAvailable: Boolean, val areShortcutsAvailable: Boolean,
val showSearchShortcutsSetting: Boolean,
val showClipboardSuggestions: Boolean, val showClipboardSuggestions: Boolean,
val showHistorySuggestions: Boolean, val showHistorySuggestions: Boolean,
val showBookmarkSuggestions: Boolean, val showBookmarkSuggestions: Boolean,
@ -81,16 +91,9 @@ fun createInitialSearchFragmentState(
): SearchFragmentState { ): SearchFragmentState {
val settings = components.settings val settings = components.settings
val tab = tabId?.let { components.core.store.state.findTab(it) } val tab = tabId?.let { components.core.store.state.findTab(it) }
val url = tab?.content?.url.orEmpty() val url = tab?.content?.url.orEmpty()
val currentSearchEngine = SearchEngineSource.Default(
components.search.provider.getDefaultEngine(activity)
)
val browsingMode = activity.browsingModeManager.mode val shouldShowSearchSuggestions = when (activity.browsingModeManager.mode) {
val areShortcutsAvailable = components.search.provider.areShortcutsAvailable(activity)
val shouldShowSearchSuggestions = when (browsingMode) {
BrowsingMode.Normal -> settings.shouldShowSearchSuggestions BrowsingMode.Normal -> settings.shouldShowSearchSuggestions
BrowsingMode.Private -> BrowsingMode.Private ->
settings.shouldShowSearchSuggestions && settings.shouldShowSearchSuggestionsInPrivate settings.shouldShowSearchSuggestions && settings.shouldShowSearchSuggestionsInPrivate
@ -100,14 +103,13 @@ fun createInitialSearchFragmentState(
query = url, query = url,
url = url, url = url,
searchTerms = tab?.content?.searchTerms.orEmpty(), searchTerms = tab?.content?.searchTerms.orEmpty(),
searchEngineSource = currentSearchEngine, searchEngineSource = SearchEngineSource.None,
defaultEngineSource = currentSearchEngine, defaultEngine = null,
showSearchSuggestions = shouldShowSearchSuggestions, showSearchSuggestions = shouldShowSearchSuggestions,
showSearchSuggestionsHint = false, showSearchSuggestionsHint = false,
showSearchShortcuts = url.isEmpty() && showSearchShortcuts = false,
areShortcutsAvailable && areShortcutsAvailable = false,
settings.shouldShowSearchShortcuts, showSearchShortcutsSetting = settings.shouldShowSearchShortcuts,
areShortcutsAvailable = areShortcutsAvailable,
showClipboardSuggestions = settings.shouldShowClipboardSuggestions, showClipboardSuggestions = settings.shouldShowClipboardSuggestions,
showHistorySuggestions = settings.shouldShowHistorySuggestions, showHistorySuggestions = settings.shouldShowHistorySuggestions,
showBookmarkSuggestions = settings.shouldShowBookmarkSuggestions, showBookmarkSuggestions = settings.shouldShowBookmarkSuggestions,
@ -124,11 +126,14 @@ fun createInitialSearchFragmentState(
sealed class SearchFragmentAction : Action { sealed class SearchFragmentAction : Action {
data class SetShowSearchSuggestions(val show: Boolean) : SearchFragmentAction() data class SetShowSearchSuggestions(val show: Boolean) : SearchFragmentAction()
data class SearchShortcutEngineSelected(val engine: SearchEngine) : SearchFragmentAction() data class SearchShortcutEngineSelected(val engine: SearchEngine) : SearchFragmentAction()
data class SelectNewDefaultSearchEngine(val engine: SearchEngine) : SearchFragmentAction()
data class ShowSearchShortcutEnginePicker(val show: Boolean) : SearchFragmentAction() data class ShowSearchShortcutEnginePicker(val show: Boolean) : SearchFragmentAction()
data class UpdateShortcutsAvailability(val areShortcutsAvailable: Boolean) : SearchFragmentAction()
data class AllowSearchSuggestionsInPrivateModePrompt(val show: Boolean) : SearchFragmentAction() data class AllowSearchSuggestionsInPrivateModePrompt(val show: Boolean) : SearchFragmentAction()
data class UpdateQuery(val query: String) : SearchFragmentAction() data class UpdateQuery(val query: String) : SearchFragmentAction()
/**
* Updates the local `SearchFragmentState` from the global `SearchState` in `BrowserStore`.
*/
data class UpdateSearchState(val search: SearchState) : SearchFragmentAction()
} }
/** /**
@ -143,15 +148,26 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen
) )
is SearchFragmentAction.ShowSearchShortcutEnginePicker -> is SearchFragmentAction.ShowSearchShortcutEnginePicker ->
state.copy(showSearchShortcuts = action.show && state.areShortcutsAvailable) state.copy(showSearchShortcuts = action.show && state.areShortcutsAvailable)
is SearchFragmentAction.UpdateShortcutsAvailability ->
state.copy(areShortcutsAvailable = action.areShortcutsAvailable)
is SearchFragmentAction.UpdateQuery -> is SearchFragmentAction.UpdateQuery ->
state.copy(query = action.query) state.copy(query = action.query)
is SearchFragmentAction.SelectNewDefaultSearchEngine ->
state.copy(searchEngineSource = SearchEngineSource.Default(action.engine))
is SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt -> is SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt ->
state.copy(showSearchSuggestionsHint = action.show) state.copy(showSearchSuggestionsHint = action.show)
is SearchFragmentAction.SetShowSearchSuggestions -> is SearchFragmentAction.SetShowSearchSuggestions ->
state.copy(showSearchSuggestions = action.show) state.copy(showSearchSuggestions = action.show)
is SearchFragmentAction.UpdateSearchState -> {
state.copy(
defaultEngine = action.search.selectedOrDefaultSearchEngine,
areShortcutsAvailable = action.search.searchEngines.size > 1,
showSearchShortcuts = state.url.isEmpty() &&
state.showSearchShortcutsSetting &&
action.search.searchEngines.size > 1,
searchEngineSource = if (state.searchEngineSource !is SearchEngineSource.Shortcut) {
action.search.selectedOrDefaultSearchEngine?.let { SearchEngineSource.Default(it) }
?: SearchEngineSource.None
} else {
state.searchEngineSource
}
)
}
} }
} }

@ -4,8 +4,8 @@
package org.mozilla.fenix.search.awesomebar package org.mozilla.fenix.search.awesomebar
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.search.SearchEngine
/** /**
* Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want * Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want

@ -10,8 +10,8 @@ import androidx.core.graphics.BlendModeCompat.SRC_IN
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import mozilla.components.browser.awesomebar.BrowserAwesomeBar import mozilla.components.browser.awesomebar.BrowserAwesomeBar
import mozilla.components.browser.search.DefaultSearchEngineProvider import mozilla.components.browser.search.DefaultSearchEngineProvider
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.awesomebar.AwesomeBar
import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineSession
import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
@ -20,9 +20,10 @@ import mozilla.components.feature.awesomebar.provider.SearchActionProvider
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.search.SearchUseCases
import mozilla.components.browser.search.ext.toDefaultSearchEngineProvider import mozilla.components.feature.search.ext.legacy
import mozilla.components.feature.syncedtabs.DeviceIndicators import mozilla.components.feature.search.ext.toDefaultSearchEngineProvider
import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.syncedtabs.DeviceIndicators
import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider
import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.ktx.android.content.getColorFromAttr
@ -32,6 +33,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.search.SearchEngineSource import org.mozilla.fenix.search.SearchEngineSource
import org.mozilla.fenix.search.SearchFragmentState import org.mozilla.fenix.search.SearchFragmentState
import mozilla.components.browser.search.SearchEngine as LegacySearchEngine
/** /**
* View that contains and configures the BrowserAwesomeBar * View that contains and configures the BrowserAwesomeBar
@ -65,7 +67,7 @@ class AwesomeBarView(
private val searchUseCase = object : SearchUseCases.SearchUseCase { private val searchUseCase = object : SearchUseCases.SearchUseCase {
override fun invoke( override fun invoke(
searchTerms: String, searchTerms: String,
searchEngine: SearchEngine?, searchEngine: mozilla.components.browser.search.SearchEngine?,
parentSession: Session? parentSession: Session?
) { ) {
interactor.onSearchTermsTapped(searchTerms) interactor.onSearchTermsTapped(searchTerms)
@ -75,7 +77,7 @@ class AwesomeBarView(
private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase { private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase {
override fun invoke( override fun invoke(
searchTerms: String, searchTerms: String,
searchEngine: SearchEngine?, searchEngine: mozilla.components.browser.search.SearchEngine?,
parentSession: Session? parentSession: Session?
) { ) {
interactor.onSearchTermsTapped(searchTerms) interactor.onSearchTermsTapped(searchTerms)
@ -148,9 +150,7 @@ class AwesomeBarView(
defaultSearchSuggestionProvider = defaultSearchSuggestionProvider =
SearchSuggestionProvider( SearchSuggestionProvider(
context = activity, context = activity,
defaultSearchEngineProvider = components.search.searchEngineManager.toDefaultSearchEngineProvider( defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(),
activity
),
searchUseCase = searchUseCase, searchUseCase = searchUseCase,
fetchClient = components.core.client, fetchClient = components.core.client,
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
@ -163,9 +163,7 @@ class AwesomeBarView(
defaultSearchActionProvider = defaultSearchActionProvider =
SearchActionProvider( SearchActionProvider(
defaultSearchEngineProvider = components.search.searchEngineManager.toDefaultSearchEngineProvider( defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(),
activity
),
searchUseCase = searchUseCase, searchUseCase = searchUseCase,
icon = searchBitmap, icon = searchBitmap,
showDescription = false showDescription = false
@ -173,7 +171,7 @@ class AwesomeBarView(
shortcutsEnginePickerProvider = shortcutsEnginePickerProvider =
ShortcutsSuggestionProvider( ShortcutsSuggestionProvider(
searchEngineProvider = components.search.provider, store = components.core.store,
context = activity, context = activity,
selectShortcutEngine = interactor::onSearchShortcutEngineSelected, selectShortcutEngine = interactor::onSearchShortcutEngineSelected,
selectShortcutEngineSettings = interactor::onClickSearchEngineSettings selectShortcutEngineSettings = interactor::onClickSearchEngineSettings
@ -288,6 +286,7 @@ class AwesomeBarView(
is SearchEngineSource.Shortcut -> getSuggestionProviderForEngine( is SearchEngineSource.Shortcut -> getSuggestionProviderForEngine(
state.searchEngineSource.searchEngine state.searchEngineSource.searchEngine
) )
is SearchEngineSource.None -> emptyList()
} }
} }
@ -311,22 +310,20 @@ class AwesomeBarView(
BrowsingMode.Normal -> components.core.engine BrowsingMode.Normal -> components.core.engine
BrowsingMode.Private -> null BrowsingMode.Private -> null
} }
val searchEngine =
components.search.provider.installedSearchEngines(activity).list.find { it.name == engine.name }
?: components.search.provider.getDefaultEngine(activity)
listOf( listOf(
SearchActionProvider( SearchActionProvider(
defaultSearchEngineProvider = object : DefaultSearchEngineProvider { defaultSearchEngineProvider = object : DefaultSearchEngineProvider {
override fun getDefaultSearchEngine(): SearchEngine? = searchEngine override fun getDefaultSearchEngine(): LegacySearchEngine? =
override suspend fun retrieveDefaultSearchEngine(): SearchEngine? = engine.legacy()
searchEngine override suspend fun retrieveDefaultSearchEngine(): LegacySearchEngine? =
engine.legacy()
}, },
searchUseCase = shortcutSearchUseCase, searchUseCase = shortcutSearchUseCase,
icon = searchBitmap icon = searchBitmap
), ),
SearchSuggestionProvider( SearchSuggestionProvider(
searchEngine, engine.legacy(),
shortcutSearchUseCase, shortcutSearchUseCase,
components.core.client, components.core.client,
limit = 3, limit = 3,

@ -7,17 +7,18 @@ package org.mozilla.fenix.search.awesomebar
import android.content.Context import android.content.Context
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.awesomebar.AwesomeBar
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import java.util.UUID import java.util.UUID
/** /**
* A [AwesomeBar.SuggestionProvider] implementation that provides search engine suggestions. * A [AwesomeBar.SuggestionProvider] implementation that provides search engine suggestions.
*/ */
class ShortcutsSuggestionProvider( class ShortcutsSuggestionProvider(
private val searchEngineProvider: FenixSearchEngineProvider, private val store: BrowserStore,
private val context: Context, private val context: Context,
private val selectShortcutEngine: (engine: SearchEngine) -> Unit, private val selectShortcutEngine: (engine: SearchEngine) -> Unit,
private val selectShortcutEngineSettings: () -> Unit private val selectShortcutEngineSettings: () -> Unit
@ -34,10 +35,10 @@ class ShortcutsSuggestionProvider(
override suspend fun onInputChanged(text: String): List<AwesomeBar.Suggestion> { override suspend fun onInputChanged(text: String): List<AwesomeBar.Suggestion> {
val suggestions = mutableListOf<AwesomeBar.Suggestion>() val suggestions = mutableListOf<AwesomeBar.Suggestion>()
searchEngineProvider.installedSearchEngines(context).list.mapTo(suggestions) { store.state.search.searchEngines.mapTo(suggestions) {
AwesomeBar.Suggestion( AwesomeBar.Suggestion(
provider = this, provider = this,
id = it.identifier, id = it.id,
icon = it.icon, icon = it.icon,
title = it.name, title = it.name,
onSuggestionClicked = { onSuggestionClicked = {

@ -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

@ -148,18 +148,22 @@ class ToolbarView(
isInitialized = true isInitialized = true
} }
val iconSize = val searchEngine = searchState.searchEngineSource.searchEngine
context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
val scaledIcon = Bitmap.createScaledBitmap( if (searchEngine != null) {
searchState.searchEngineSource.searchEngine.icon, val iconSize =
iconSize, context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
iconSize,
true
)
val icon = BitmapDrawable(context.resources, scaledIcon) val scaledIcon = Bitmap.createScaledBitmap(
searchEngine.icon,
iconSize,
iconSize,
true
)
val icon = BitmapDrawable(context.resources, scaledIcon)
view.edit.setIcon(icon, searchState.searchEngineSource.searchEngine.name) view.edit.setIcon(icon, searchEngine.name)
}
} }
} }

@ -19,26 +19,34 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.custom_search_engine.* import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_form
import kotlinx.android.synthetic.main.fragment_add_search_engine.* import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_name_field
import kotlinx.android.synthetic.main.search_engine_radio_button.view.* import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_search_string_field
import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engines_learn_more
import kotlinx.android.synthetic.main.custom_search_engine.edit_engine_name
import kotlinx.android.synthetic.main.custom_search_engine.edit_search_string
import kotlinx.android.synthetic.main.fragment_add_search_engine.search_engine_group
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.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.icons.IconRequest
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.availableSearchEngines
import mozilla.components.feature.search.ext.createSearchEngine
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event 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.components
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.perf.runBlockingIncrement
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import java.util.Locale
@SuppressWarnings("LargeClass", "TooManyFunctions") @SuppressWarnings("LargeClass", "TooManyFunctions")
class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
@ -51,14 +59,13 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
availableEngines = runBlockingIncrement { availableEngines = requireContext()
requireContext() .components
.components .core
.search .store
.provider .state
.uninstalledSearchEngines(requireContext()) .search
.list .availableSearchEngines
}
selectedIndex = if (availableEngines.isEmpty()) CUSTOM_INDEX else FIRST_INDEX selectedIndex = if (availableEngines.isEmpty()) CUSTOM_INDEX else FIRST_INDEX
} }
@ -72,7 +79,7 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
) )
val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine -> val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine ->
val engineId = engine.identifier val engineId = engine.id
val engineItem = makeButtonFromSearchEngine( val engineItem = makeButtonFromSearchEngine(
engine = engine, engine = engine,
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@ -123,7 +130,8 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
CUSTOM_INDEX -> createCustomEngine() CUSTOM_INDEX -> createCustomEngine()
else -> { else -> {
val engine = availableEngines[selectedIndex] val engine = availableEngines[selectedIndex]
installEngine(engine) requireComponents.useCases.searchUseCases.addSearchEngine(engine)
findNavController().popBackStack()
} }
} }
@ -141,9 +149,9 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
val name = edit_engine_name.text?.toString()?.trim() ?: "" val name = edit_engine_name.text?.toString()?.trim() ?: ""
val searchString = edit_search_string.text?.toString() ?: "" val searchString = edit_search_string.text?.toString() ?: ""
val hasError = checkForErrors(name, searchString) if (checkForErrors(name, searchString)) {
return
if (hasError) { return } }
viewLifecycleOwner.lifecycleScope.launch(Main) { viewLifecycleOwner.lifecycleScope.launch(Main) {
val result = withContext(IO) { val result = withContext(IO) {
@ -159,22 +167,14 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
.getString(R.string.search_add_custom_engine_error_cannot_reach, name) .getString(R.string.search_add_custom_engine_error_cannot_reach, name)
} }
SearchStringValidator.Result.Success -> { SearchStringValidator.Result.Success -> {
try { val searchEngine = createSearchEngine(
CustomSearchEngineStore.addSearchEngine( name,
context = requireContext(), searchString.toSearchUrl(),
engineName = name, requireComponents.core.icons.loadIcon(IconRequest(searchString)).await().bitmap
searchQuery = searchString )
)
} catch (engineNameExists: CustomSearchEngineStore.EngineNameAlreadyExists) { requireComponents.useCases.searchUseCases.addSearchEngine(searchEngine)
custom_search_engine_name_field.error =
String.format(
resources.getString(
R.string.search_add_custom_engine_error_existing_name
), name
)
return@launch
}
requireComponents.search.provider.reload()
val successMessage = resources val successMessage = resources
.getString(R.string.search_add_custom_engine_success_message, name) .getString(R.string.search_add_custom_engine_success_message, name)
@ -196,27 +196,12 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
} }
fun checkForErrors(name: String, searchString: String): Boolean { fun checkForErrors(name: String, searchString: String): Boolean {
val existingIdentifiers = requireComponents return when {
.search
.provider
.allSearchEngineIdentifiers()
.map { it.toLowerCase(Locale.ROOT) }
val hasError = when {
name.isEmpty() -> { name.isEmpty() -> {
custom_search_engine_name_field.error = resources custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_empty_name) .getString(R.string.search_add_custom_engine_error_empty_name)
true true
} }
existingIdentifiers.contains(name.toLowerCase(Locale.ROOT)) -> {
custom_search_engine_name_field.error =
String.format(
resources.getString(
R.string.search_add_custom_engine_error_existing_name
), name
)
true
}
searchString.isEmpty() -> { searchString.isEmpty() -> {
custom_search_engine_search_string_field.error = custom_search_engine_search_string_field.error =
resources.getString(R.string.search_add_custom_engine_error_empty_search_string) resources.getString(R.string.search_add_custom_engine_error_empty_search_string)
@ -229,21 +214,6 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
} }
else -> false else -> false
} }
return hasError
}
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) { override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
@ -303,3 +273,7 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine),
private const val FIRST_INDEX = 0 private const val FIRST_INDEX = 0
} }
} }
private fun String.toSearchUrl(): String {
return replace("%s", "{searchTerms}")
}

@ -4,7 +4,6 @@
package org.mozilla.fenix.settings.search package org.mozilla.fenix.settings.search
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
@ -14,21 +13,23 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.custom_search_engine.* import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_name_field
import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_search_string_field
import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engines_learn_more
import kotlinx.android.synthetic.main.custom_search_engine.edit_engine_name
import kotlinx.android.synthetic.main.custom_search_engine.edit_search_string
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import java.util.Locale
/** /**
* Fragment to enter a custom search engine name and URL template. * Fragment to enter a custom search engine name and URL template.
@ -41,17 +42,21 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
searchEngine = CustomSearchEngineStore.loadCustomSearchEngines(requireContext()).first {
it.identifier == args.searchEngineIdentifier searchEngine = requireNotNull(
} requireComponents.core.store.state.search.customSearchEngines.find { engine ->
engine.id == args.searchEngineIdentifier
}
)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val url = searchEngine.resultUrls[0]
edit_engine_name.setText(searchEngine.name) edit_engine_name.setText(searchEngine.name)
val decodedUrl = Uri.decode(searchEngine.buildSearchUrl("%s")) edit_search_string.setText(url.toEditableUrl())
edit_search_string.setText(decodedUrl)
custom_search_engines_learn_more.setOnClickListener { custom_search_engines_learn_more.setOnClickListener {
(activity as HomeActivity).openToBrowserAndLoad( (activity as HomeActivity).openToBrowserAndLoad(
@ -92,9 +97,7 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
val name = edit_engine_name.text?.toString()?.trim() ?: "" val name = edit_engine_name.text?.toString()?.trim() ?: ""
val searchString = edit_search_string.text?.toString() ?: "" val searchString = edit_search_string.text?.toString() ?: ""
val hasError = checkForErrors(name, searchString) if (checkForErrors(name, searchString)) {
if (hasError) {
return return
} }
@ -111,14 +114,15 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
custom_search_engine_search_string_field.error = resources custom_search_engine_search_string_field.error = resources
.getString(R.string.search_add_custom_engine_error_cannot_reach, name) .getString(R.string.search_add_custom_engine_error_cannot_reach, name)
} }
SearchStringValidator.Result.Success -> { SearchStringValidator.Result.Success -> {
CustomSearchEngineStore.updateSearchEngine( val update = searchEngine.copy(
context = requireContext(), name = name,
oldEngineName = args.searchEngineIdentifier, resultUrls = listOf(searchString.toSearchUrl())
newEngineName = name,
searchQuery = searchString
) )
requireComponents.search.provider.reload()
requireComponents.useCases.searchUseCases.addSearchEngine(update)
val successMessage = resources val successMessage = resources
.getString(R.string.search_edit_custom_engine_success_message, name) .getString(R.string.search_edit_custom_engine_success_message, name)
@ -131,9 +135,7 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
.setText(successMessage) .setText(successMessage)
.show() .show()
} }
if (args.isDefaultSearchEngine) {
requireComponents.search.provider.setDefaultEngine(requireContext(), name)
}
findNavController().popBackStack() findNavController().popBackStack()
} }
} }
@ -141,28 +143,12 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
} }
private fun checkForErrors(name: String, searchString: String): Boolean { private fun checkForErrors(name: String, searchString: String): Boolean {
val existingIdentifiers = requireComponents return when {
.search
.provider
.allSearchEngineIdentifiers()
.map { it.toLowerCase(Locale.ROOT) }
val nameHasChanged = name != args.searchEngineIdentifier
val hasError = when {
name.isEmpty() -> { name.isEmpty() -> {
custom_search_engine_name_field.error = resources custom_search_engine_name_field.error = resources
.getString(R.string.search_add_custom_engine_error_empty_name) .getString(R.string.search_add_custom_engine_error_empty_name)
true true
} }
existingIdentifiers.contains(name.toLowerCase(Locale.ROOT)) && nameHasChanged -> {
custom_search_engine_name_field.error =
String.format(
resources.getString(
R.string.search_add_custom_engine_error_existing_name
), name
)
true
}
searchString.isEmpty() -> { searchString.isEmpty() -> {
custom_search_engine_search_string_field.error = custom_search_engine_search_string_field.error =
resources.getString(R.string.search_add_custom_engine_error_empty_search_string) resources.getString(R.string.search_add_custom_engine_error_empty_search_string)
@ -175,6 +161,13 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng
} }
else -> false else -> false
} }
return hasError
} }
} }
private fun String.toEditableUrl(): String {
return replace("{searchTerms}", "%s")
}
private fun String.toSearchUrl(): String {
return replace("%s", "{searchTerms}")
}

@ -5,27 +5,165 @@
package org.mozilla.fenix.settings.search package org.mozilla.fenix.settings.search
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.drawable.BitmapDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CompoundButton import android.widget.CompoundButton
import mozilla.components.browser.search.SearchEngine 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.flow
import mozilla.components.support.ktx.android.view.toScope
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.utils.allowUndo
class RadioSearchEngineListPreference @JvmOverloads constructor( class RadioSearchEngineListPreference @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = android.R.attr.preferenceStyle defStyleAttr: Int = android.R.attr.preferenceStyle
) : SearchEngineListPreference(context, attrs, defStyleAttr) { ) : Preference(context, attrs, defStyleAttr), CompoundButton.OnCheckedChangeListener {
override val itemResId: Int val itemResId: Int
get() = R.layout.search_engine_radio_button get() = R.layout.search_engine_radio_button
override fun updateDefaultItem(defaultButton: CompoundButton) { init {
defaultButton.isChecked = true layoutResource = R.layout.preference_search_engine_chooser
} }
override fun onSearchEngineSelected(searchEngine: SearchEngine) { override fun onBindViewHolder(holder: PreferenceViewHolder) {
context.components.search.provider.setDefaultEngine(context, searchEngine.identifier) super.onBindViewHolder(holder)
context.settings().defaultSearchEngineName = searchEngine.name
subscribeToSearchEngineUpdates(
context.components.core.store,
holder.itemView
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun subscribeToSearchEngineUpdates(store: BrowserStore, view: View) = view.toScope().launch {
store.flow()
.map { state -> state.search }
.ifChanged()
.collect { state -> refreshSearchEngineViews(view, state) }
}
private fun refreshSearchEngineViews(view: View, state: SearchState) {
val searchEngineGroup = view.findViewById<RadioGroup>(R.id.search_engine_group)
searchEngineGroup!!.removeAllViews()
val layoutInflater = LayoutInflater.from(context)
val layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
state.searchEngines.forEach { engine ->
val searchEngineView = makeButtonFromSearchEngine(
engine = engine,
layoutInflater = layoutInflater,
res = context.resources,
allowDeletion = state.searchEngines.size > 1,
isSelected = engine == state.selectedOrDefaultSearchEngine
)
searchEngineGroup.addView(searchEngineView, layoutParams)
}
}
private fun makeButtonFromSearchEngine(
engine: SearchEngine,
layoutInflater: LayoutInflater,
res: Resources,
allowDeletion: Boolean,
isSelected: Boolean
): View {
val isCustomSearchEngine = engine.type == SearchEngine.Type.CUSTOM
val wrapper = layoutInflater.inflate(itemResId, null) as LinearLayout
wrapper.setOnClickListener { wrapper.radio_button.isChecked = true }
wrapper.radio_button.tag = engine.id
wrapper.radio_button.isChecked = isSelected
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(wrapper, 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)
wrapper.engine_icon.setImageDrawable(engineIcon)
return wrapper
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
val searchEngineId = buttonView.tag.toString()
val engine = requireNotNull(
context.components.core.store.state.search.searchEngines.find { searchEngine ->
searchEngine.id == searchEngineId
}
)
context.components.useCases.searchUseCases.selectSearchEngine(engine)
}
private fun editCustomSearchEngine(view: View, engine: SearchEngine) {
val directions = SearchEngineFragmentDirections
.actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.id)
Navigation.findNavController(view).navigate(directions)
}
private fun deleteSearchEngine(
context: Context,
engine: SearchEngine
) {
context.components.useCases.searchUseCases.removeSearchEngine(engine)
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.useCases.searchUseCases.addSearchEngine(engine)
},
operation = {}
)
} }
} }

@ -70,15 +70,11 @@ class SearchEngineFragment : PreferenceFragmentCompat() {
isChecked = context.settings().shouldShowClipboardSuggestions isChecked = context.settings().shouldShowClipboardSuggestions
} }
val searchEngineListPreference =
requirePreference<SearchEngineListPreference>(R.string.pref_key_search_engine_list)
val showVoiceSearchPreference = val showVoiceSearchPreference =
requirePreference<SwitchPreference>(R.string.pref_key_show_voice_search).apply { requirePreference<SwitchPreference>(R.string.pref_key_show_voice_search).apply {
isChecked = context.settings().shouldShowVoiceSearch isChecked = context.settings().shouldShowVoiceSearch
} }
searchEngineListPreference.reload(requireContext())
searchSuggestionsPreference.onPreferenceChangeListener = SharedPreferenceUpdater() searchSuggestionsPreference.onPreferenceChangeListener = SharedPreferenceUpdater()
showSearchShortcuts.onPreferenceChangeListener = SharedPreferenceUpdater() showSearchShortcuts.onPreferenceChangeListener = SharedPreferenceUpdater()
showHistorySuggestions.onPreferenceChangeListener = SharedPreferenceUpdater() showHistorySuggestions.onPreferenceChangeListener = SharedPreferenceUpdater()

@ -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)
}
}

@ -943,9 +943,6 @@
<argument <argument
android:name="searchEngineIdentifier" android:name="searchEngineIdentifier"
app:argType="string" /> app:argType="string" />
<argument
android:name="isDefaultSearchEngine"
app:argType="boolean" />
</fragment> </fragment>
</navigation> </navigation>
</navigation> </navigation>

@ -38,7 +38,6 @@ class MigratingFenixApplication : FenixApplication() {
this.components.addonUpdater this.components.addonUpdater
) )
.migrateTelemetryIdentifiers() .migrateTelemetryIdentifiers()
.migrateSearchEngine(this.components.search.searchEngineManager)
.build() .build()
} }

@ -16,14 +16,12 @@ class TestComponents(private val context: Context) : Components(context) {
} }
override val services by lazy { Services(context, backgroundServices.accountManager) } override val services by lazy { Services(context, backgroundServices.accountManager) }
override val core by lazy { TestCore(context, analytics.crashReporter) } override val core by lazy { TestCore(context, analytics.crashReporter) }
override val search by lazy { Search(context) }
override val useCases by lazy { override val useCases by lazy {
UseCases( UseCases(
context, context,
core.engine, core.engine,
core.sessionManager, core.sessionManager,
core.store, core.store,
search.searchEngineManager,
core.webAppShortcutManager, core.webAppShortcutManager,
core.topSitesStorage core.topSitesStorage
) )

@ -7,6 +7,7 @@ package org.mozilla.fenix.components.metrics
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.service.glean.testing.GleanTestRule import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -35,7 +36,9 @@ class GleanMetricsServiceTest {
@Before @Before
fun setup() { fun setup() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
gleanService = GleanMetricsService(testContext, browsersCache, mozillaProductDetector)
val store = BrowserStore()
gleanService = GleanMetricsService(testContext, lazy { store }, browsersCache, mozillaProductDetector)
} }
@Test @Test

@ -6,8 +6,8 @@ package org.mozilla.fenix.components.metrics
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.support.test.robolectric.testContext import mozilla.components.browser.state.store.BrowserStore
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -22,16 +22,16 @@ class MetricsUtilsTestRoboelectric {
@Test @Test
fun createSearchEvent() { fun createSearchEvent() {
val context = testContext val store = BrowserStore()
val engine: SearchEngine = mockk(relaxed = true) val engine: SearchEngine = mockk(relaxed = true)
every { engine.identifier } returns MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER every { engine.id } returns MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER
Assert.assertEquals( Assert.assertEquals(
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.suggestion", "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.suggestion",
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
engine, engine,
context, store,
Event.PerformedSearch.SearchAccessPoint.SUGGESTION Event.PerformedSearch.SearchAccessPoint.SUGGESTION
)?.eventSource?.countLabel )?.eventSource?.countLabel
) )
@ -39,7 +39,7 @@ class MetricsUtilsTestRoboelectric {
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.action", "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.action",
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
engine, engine,
context, store,
Event.PerformedSearch.SearchAccessPoint.ACTION Event.PerformedSearch.SearchAccessPoint.ACTION
)?.eventSource?.countLabel )?.eventSource?.countLabel
) )
@ -47,7 +47,7 @@ class MetricsUtilsTestRoboelectric {
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.widget", "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.widget",
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
engine, engine,
context, store,
Event.PerformedSearch.SearchAccessPoint.WIDGET Event.PerformedSearch.SearchAccessPoint.WIDGET
)?.eventSource?.countLabel )?.eventSource?.countLabel
) )
@ -55,7 +55,7 @@ class MetricsUtilsTestRoboelectric {
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.shortcut", "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.shortcut",
MetricsUtils.createSearchEvent( MetricsUtils.createSearchEvent(
engine, engine,
context, store,
Event.PerformedSearch.SearchAccessPoint.SHORTCUT Event.PerformedSearch.SearchAccessPoint.SHORTCUT
)?.eventSource?.countLabel )?.eventSource?.countLabel
) )

@ -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
}
}

@ -12,9 +12,11 @@ import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.SearchEngineManager
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.Engine
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.TabsUseCases
@ -33,7 +35,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.searchEngineManager
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
@ -67,16 +68,27 @@ class DefaultSessionControlControllerTest {
wasSwiped: Boolean, wasSwiped: Boolean,
handleSwipedItemDeletionCancel: () -> Unit handleSwipedItemDeletionCancel: () -> Unit
) -> Unit = mockk(relaxed = true) ) -> Unit = mockk(relaxed = true)
private val searchEngine = mockk<SearchEngine>(relaxed = true)
private val searchEngineManager = mockk<SearchEngineManager>(relaxed = true)
private val settings: Settings = mockk(relaxed = true) private val settings: Settings = mockk(relaxed = true)
private val analytics: Analytics = mockk(relaxed = true) private val analytics: Analytics = mockk(relaxed = true)
private val scope = TestCoroutineScope() private val scope = TestCoroutineScope()
private val searchEngine = SearchEngine(
id = "test",
name = "Test Engine",
icon = mockk(relaxed = true),
type = SearchEngine.Type.BUNDLED,
resultUrls = listOf("https://example.org/?q={searchTerms}")
)
private lateinit var store: BrowserStore
private lateinit var controller: DefaultSessionControlController private lateinit var controller: DefaultSessionControlController
@Before @Before
fun setup() { fun setup() {
store = BrowserStore(BrowserState(
search = SearchState(
regionSearchEngines = listOf(searchEngine)
)
))
every { fragmentStore.state } returns HomeFragmentState( every { fragmentStore.state } returns HomeFragmentState(
collections = emptyList(), collections = emptyList(),
expandedCollections = emptySet(), expandedCollections = emptySet(),
@ -90,15 +102,13 @@ class DefaultSessionControlControllerTest {
every { id } returns R.id.homeFragment every { id } returns R.id.homeFragment
} }
every { activity.components.settings } returns settings every { activity.components.settings } returns settings
every { activity.components.search.provider.getDefaultEngine(activity) } returns searchEngine
every { activity.settings() } returns settings every { activity.settings() } returns settings
every { activity.searchEngineManager } returns searchEngineManager
every { searchEngineManager.defaultSearchEngine } returns searchEngine
every { activity.components.analytics } returns analytics every { activity.components.analytics } returns analytics
every { analytics.metrics } returns metrics every { analytics.metrics } returns metrics
controller = DefaultSessionControlController( controller = DefaultSessionControlController(
activity = activity, activity = activity,
store = store,
settings = settings, settings = settings,
engine = engine, engine = engine,
metrics = metrics, metrics = metrics,

@ -6,18 +6,21 @@ package org.mozilla.fenix.home.intent
import android.content.Intent import android.content.Intent
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.test.core.app.ApplicationProvider
import io.mockk.Called import io.mockk.Called
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.search.ext.createSearchEngine
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING
@ -29,16 +32,32 @@ class SpeechProcessingIntentProcessorTest {
private val out: Intent = mockk(relaxed = true) private val out: Intent = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true)
private val searchEngine = createSearchEngine(
name = "Test",
url = "https://www.example.org/?q={searchTerms}",
icon = mockk()
)
private lateinit var store: BrowserStore
@Before @Before
fun setup() { fun setup() {
val searchEngine = mockk<SearchEngine>(relaxed = true) val searchEngine = searchEngine
every { activity.components.search.searchEngineManager.defaultSearchEngine } returns searchEngine
every { activity.components.search.provider.getDefaultEngine(activity) } returns searchEngine store = BrowserStore(BrowserState(
search = SearchState(
customSearchEngines = listOf(searchEngine),
userSelectedSearchEngineId = searchEngine.id,
complete = true
)
))
every { activity.applicationContext } returns ApplicationProvider.getApplicationContext()
} }
@Test @Test
fun `do not process blank intents`() { fun `do not process blank intents`() {
val processor = SpeechProcessingIntentProcessor(activity, metrics) val processor = SpeechProcessingIntentProcessor(activity, store, metrics)
processor.process(Intent(), navController, out) processor.process(Intent(), navController, out)
verify { activity wasNot Called } verify { activity wasNot Called }
@ -52,7 +71,7 @@ class SpeechProcessingIntentProcessorTest {
val intent = Intent().apply { val intent = Intent().apply {
putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false) putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false)
} }
val processor = SpeechProcessingIntentProcessor(activity, metrics) val processor = SpeechProcessingIntentProcessor(activity, store, metrics)
processor.process(intent, navController, out) processor.process(intent, navController, out)
verify { activity wasNot Called } verify { activity wasNot Called }
@ -61,35 +80,14 @@ class SpeechProcessingIntentProcessorTest {
verify { metrics wasNot Called } verify { metrics wasNot Called }
} }
@Test
fun `process when open extra is true`() {
val intent = Intent().apply {
putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, true)
}
val processor = SpeechProcessingIntentProcessor(activity, metrics)
processor.process(intent, navController, out)
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = "",
newTab = true,
from = BrowserDirection.FromGlobal,
forceSearch = true
)
}
verify { navController wasNot Called }
verify { out.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false) }
}
@Test @Test
fun `reads the speech processing extra`() { fun `reads the speech processing extra`() {
val intent = Intent().apply { val intent = Intent().apply {
putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, true) putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, true)
putExtra(SPEECH_PROCESSING, "hello world") putExtra(SPEECH_PROCESSING, "hello world")
} }
val processor = SpeechProcessingIntentProcessor(activity, metrics)
val processor = SpeechProcessingIntentProcessor(activity, store, metrics)
processor.process(intent, mockk(), mockk(relaxed = true)) processor.process(intent, mockk(), mockk(relaxed = true))
verify { verify {
@ -97,7 +95,8 @@ class SpeechProcessingIntentProcessorTest {
searchTermOrURL = "hello world", searchTermOrURL = "hello world",
newTab = true, newTab = true,
from = BrowserDirection.FromGlobal, from = BrowserDirection.FromGlobal,
forceSearch = true forceSearch = true,
engine = searchEngine
) )
} }
} }

@ -19,9 +19,10 @@ import io.mockk.unmockkObject
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.store.BrowserStore
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -56,18 +57,21 @@ class SearchDialogControllerTest {
MockKAnnotations.init(this) MockKAnnotations.init(this)
mockkObject(MetricsUtils) mockkObject(MetricsUtils)
val browserStore = BrowserStore()
every { store.state.tabId } returns "test-tab-id" every { store.state.tabId } returns "test-tab-id"
every { store.state.searchEngineSource.searchEngine } returns searchEngine every { store.state.searchEngineSource.searchEngine } returns searchEngine
every { sessionManager.select(any()) } just Runs every { sessionManager.select(any()) } just Runs
every { navController.currentDestination } returns mockk { every { navController.currentDestination } returns mockk {
every { id } returns R.id.searchDialogFragment every { id } returns R.id.searchDialogFragment
} }
every { MetricsUtils.createSearchEvent(searchEngine, activity, any()) } returns null every { MetricsUtils.createSearchEvent(searchEngine, browserStore, any()) } returns null
controller = SearchDialogController( controller = SearchDialogController(
activity = activity, activity = activity,
sessionManager = sessionManager, sessionManager = sessionManager,
store = store, store = browserStore,
fragmentStore = store,
navController = navController, navController = navController,
settings = settings, settings = settings,
metrics = metrics, metrics = metrics,

@ -8,8 +8,8 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.search.SearchEngine
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

@ -10,14 +10,17 @@ import io.mockk.impl.annotations.MockK
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.RegionState
import mozilla.components.browser.search.provider.SearchEngineList import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.ContentState import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNotSame import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -26,14 +29,12 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class SearchFragmentStoreTest { class SearchFragmentStoreTest {
@MockK private lateinit var searchEngine: SearchEngine @MockK private lateinit var searchEngine: SearchEngine
@MockK private lateinit var searchProvider: FenixSearchEngineProvider
@MockK private lateinit var activity: HomeActivity @MockK private lateinit var activity: HomeActivity
@MockK(relaxed = true) private lateinit var components: Components @MockK(relaxed = true) private lateinit var components: Components
@MockK(relaxed = true) private lateinit var settings: Settings @MockK(relaxed = true) private lateinit var settings: Settings
@ -45,12 +46,6 @@ class SearchFragmentStoreTest {
override var mode: BrowsingMode = BrowsingMode.Normal override var mode: BrowsingMode = BrowsingMode.Normal
} }
every { components.settings } returns settings every { components.settings } returns settings
every { components.search.provider } returns searchProvider
every { searchProvider.getDefaultEngine(activity) } returns searchEngine
every { searchProvider.installedSearchEngines(activity) } returns SearchEngineList(
list = listOf(mockk(), mockk()),
default = searchEngine
)
} }
@Test @Test
@ -63,12 +58,13 @@ class SearchFragmentStoreTest {
query = "", query = "",
url = "", url = "",
searchTerms = "", searchTerms = "",
searchEngineSource = SearchEngineSource.Default(searchEngine), searchEngineSource = SearchEngineSource.None,
defaultEngineSource = SearchEngineSource.Default(searchEngine), defaultEngine = null,
showSearchShortcutsSetting = true,
showSearchSuggestions = false, showSearchSuggestions = false,
showSearchSuggestionsHint = false, showSearchSuggestionsHint = false,
showSearchShortcuts = true, showSearchShortcuts = false,
areShortcutsAvailable = true, areShortcutsAvailable = false,
showClipboardSuggestions = false, showClipboardSuggestions = false,
showHistorySuggestions = false, showHistorySuggestions = false,
showBookmarkSuggestions = false, showBookmarkSuggestions = false,
@ -120,12 +116,13 @@ class SearchFragmentStoreTest {
query = "https://example.com", query = "https://example.com",
url = "https://example.com", url = "https://example.com",
searchTerms = "search terms", searchTerms = "search terms",
searchEngineSource = SearchEngineSource.Default(searchEngine), searchEngineSource = SearchEngineSource.None,
defaultEngineSource = SearchEngineSource.Default(searchEngine), defaultEngine = null,
showSearchSuggestions = false, showSearchSuggestions = false,
showSearchShortcutsSetting = false,
showSearchSuggestionsHint = false, showSearchSuggestionsHint = false,
showSearchShortcuts = false, showSearchShortcuts = false,
areShortcutsAvailable = true, areShortcutsAvailable = false,
showClipboardSuggestions = false, showClipboardSuggestions = false,
showHistorySuggestions = false, showHistorySuggestions = false,
showBookmarkSuggestions = false, showBookmarkSuggestions = false,
@ -176,16 +173,6 @@ class SearchFragmentStoreTest {
assertEquals(true, store.state.showSearchShortcuts) assertEquals(true, store.state.showSearchShortcuts)
} }
@Test
fun hideSearchShortcutEnginePicker() = runBlocking {
val initialState = emptyDefaultState()
val store = SearchFragmentStore(initialState)
store.dispatch(SearchFragmentAction.UpdateShortcutsAvailability(false)).join()
assertNotSame(initialState, store.state)
assertEquals(false, store.state.showSearchShortcuts)
}
@Test @Test
fun showSearchSuggestions() = runBlocking { fun showSearchSuggestions() = runBlocking {
val initialState = emptyDefaultState() val initialState = emptyDefaultState()
@ -213,26 +200,132 @@ class SearchFragmentStoreTest {
} }
@Test @Test
fun selectNewDefaultEngine() = runBlocking { fun `Updating SearchFragmentState from SearchState`() = runBlocking {
val initialState = emptyDefaultState() val store = SearchFragmentStore(emptyDefaultState(
val store = SearchFragmentStore(initialState) searchEngineSource = SearchEngineSource.None,
areShortcutsAvailable = false,
defaultEngine = null,
showSearchShortcutsSetting = true
))
store.dispatch(SearchFragmentAction.SelectNewDefaultSearchEngine(searchEngine)).join() assertNull(store.state.defaultEngine)
assertNotSame(initialState, store.state) assertFalse(store.state.areShortcutsAvailable)
assertEquals(SearchEngineSource.Default(searchEngine), store.state.searchEngineSource) assertFalse(store.state.showSearchShortcuts)
assertEquals(SearchEngineSource.None, store.state.searchEngineSource)
store.dispatch(
SearchFragmentAction.UpdateSearchState(
SearchState(
region = RegionState("US", "US"),
regionSearchEngines = listOf(
SearchEngine("engine-a", "Engine A", mockk(), type = SearchEngine.Type.BUNDLED),
SearchEngine("engine-b", "Engine B", mockk(), type = SearchEngine.Type.BUNDLED),
SearchEngine("engine-c", "Engine C", mockk(), type = SearchEngine.Type.BUNDLED)
),
customSearchEngines = listOf(
SearchEngine("engine-d", "Engine D", mockk(), type = SearchEngine.Type.CUSTOM),
SearchEngine("engine-e", "Engine E", mockk(), type = SearchEngine.Type.CUSTOM)
),
additionalSearchEngines = listOf(
SearchEngine("engine-f", "Engine F", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL)
),
additionalAvailableSearchEngines = listOf(
SearchEngine("engine-g", "Engine G", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
SearchEngine("engine-h", "Engine H", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL)
),
hiddenSearchEngines = listOf(
SearchEngine("engine-i", "Engine I", mockk(), type = SearchEngine.Type.BUNDLED)
),
regionDefaultSearchEngineId = "engine-b",
userSelectedSearchEngineId = null,
userSelectedSearchEngineName = null
)
)
).join()
assertNotNull(store.state.defaultEngine)
assertEquals("Engine B", store.state.defaultEngine!!.name)
assertTrue(store.state.areShortcutsAvailable)
assertTrue(store.state.showSearchShortcuts)
assertTrue(store.state.searchEngineSource is SearchEngineSource.Default)
assertNotNull(store.state.searchEngineSource.searchEngine)
assertEquals("Engine B", store.state.searchEngineSource.searchEngine!!.name)
}
@Test
fun `Updating SearchFragmentState from SearchState - shortcuts disabled`() = runBlocking {
val store = SearchFragmentStore(emptyDefaultState(
searchEngineSource = SearchEngineSource.None,
areShortcutsAvailable = false,
defaultEngine = null,
showSearchShortcutsSetting = false
))
assertNull(store.state.defaultEngine)
assertFalse(store.state.areShortcutsAvailable)
assertFalse(store.state.showSearchShortcuts)
assertEquals(SearchEngineSource.None, store.state.searchEngineSource)
store.dispatch(
SearchFragmentAction.UpdateSearchState(
SearchState(
region = RegionState("US", "US"),
regionSearchEngines = listOf(
SearchEngine("engine-a", "Engine A", mockk(), type = SearchEngine.Type.BUNDLED),
SearchEngine("engine-b", "Engine B", mockk(), type = SearchEngine.Type.BUNDLED),
SearchEngine("engine-c", "Engine C", mockk(), type = SearchEngine.Type.BUNDLED)
),
customSearchEngines = listOf(
SearchEngine("engine-d", "Engine D", mockk(), type = SearchEngine.Type.CUSTOM),
SearchEngine("engine-e", "Engine E", mockk(), type = SearchEngine.Type.CUSTOM)
),
additionalSearchEngines = listOf(
SearchEngine("engine-f", "Engine F", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL)
),
additionalAvailableSearchEngines = listOf(
SearchEngine("engine-g", "Engine G", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
SearchEngine("engine-h", "Engine H", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL)
),
hiddenSearchEngines = listOf(
SearchEngine("engine-i", "Engine I", mockk(), type = SearchEngine.Type.BUNDLED)
),
regionDefaultSearchEngineId = "engine-b",
userSelectedSearchEngineId = null,
userSelectedSearchEngineName = null
)
)
).join()
assertNotNull(store.state.defaultEngine)
assertEquals("Engine B", store.state.defaultEngine!!.name)
assertTrue(store.state.areShortcutsAvailable)
assertFalse(store.state.showSearchShortcuts)
assertTrue(store.state.searchEngineSource is SearchEngineSource.Default)
assertNotNull(store.state.searchEngineSource.searchEngine)
assertEquals("Engine B", store.state.searchEngineSource.searchEngine!!.name)
} }
private fun emptyDefaultState(): SearchFragmentState = SearchFragmentState( private fun emptyDefaultState(
searchEngineSource: SearchEngineSource = mockk(),
defaultEngine: SearchEngine? = mockk(),
areShortcutsAvailable: Boolean = true,
showSearchShortcutsSetting: Boolean = false
): SearchFragmentState = SearchFragmentState(
tabId = null, tabId = null,
url = "", url = "",
searchTerms = "", searchTerms = "",
query = "", query = "",
searchEngineSource = mockk(), searchEngineSource = searchEngineSource,
defaultEngineSource = mockk(), defaultEngine = defaultEngine,
showSearchSuggestionsHint = false, showSearchSuggestionsHint = false,
showSearchShortcutsSetting = showSearchShortcutsSetting,
showSearchSuggestions = false, showSearchSuggestions = false,
showSearchShortcuts = false, showSearchShortcuts = false,
areShortcutsAvailable = true, areShortcutsAvailable = areShortcutsAvailable,
showClipboardSuggestions = false, showClipboardSuggestions = false,
showHistorySuggestions = false, showHistorySuggestions = false,
showBookmarkSuggestions = false, showBookmarkSuggestions = false,

@ -13,15 +13,16 @@ import io.mockk.unmockkStatic
import io.mockk.verifySequence import io.mockk.verifySequence
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.search.provider.SearchEngineList import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.store.BrowserStore
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class ShortcutsSuggestionProviderTest { class ShortcutsSuggestionProviderTest {
@ -52,33 +53,32 @@ class ShortcutsSuggestionProviderTest {
@Test @Test
fun `returns suggestions from search engine provider`() = runBlockingTest { fun `returns suggestions from search engine provider`() = runBlockingTest {
val engineOne = mockk<SearchEngine> { val engineOne = mockk<SearchEngine> {
every { identifier } returns "1" every { id } returns "1"
every { name } returns "EngineOne" every { name } returns "EngineOne"
every { icon } returns mockk() every { icon } returns mockk()
} }
val engineTwo = mockk<SearchEngine> { val engineTwo = mockk<SearchEngine> {
every { identifier } returns "2" every { id } returns "2"
every { name } returns "EngineTwo" every { name } returns "EngineTwo"
every { icon } returns mockk() every { icon } returns mockk()
} }
val searchEngineProvider = mockk<FenixSearchEngineProvider> { val store = BrowserStore(BrowserState(
every { installedSearchEngines(context) } returns SearchEngineList( search = SearchState(
list = listOf(engineOne, engineTwo), regionSearchEngines = listOf(engineOne, engineTwo)
default = null
) )
} ))
val provider = ShortcutsSuggestionProvider(searchEngineProvider, context, mockk(), mockk()) val provider = ShortcutsSuggestionProvider(store, context, mockk(), mockk())
val suggestions = provider.onInputChanged("") val suggestions = provider.onInputChanged("")
assertEquals(3, suggestions.size) assertEquals(3, suggestions.size)
assertEquals(provider, suggestions[0].provider) assertEquals(provider, suggestions[0].provider)
assertEquals(engineOne.identifier, suggestions[0].id) assertEquals(engineOne.id, suggestions[0].id)
assertEquals(engineOne.icon, suggestions[0].icon) assertEquals(engineOne.icon, suggestions[0].icon)
assertEquals(engineOne.name, suggestions[0].title) assertEquals(engineOne.name, suggestions[0].title)
assertEquals(provider, suggestions[1].provider) assertEquals(provider, suggestions[1].provider)
assertEquals(engineTwo.identifier, suggestions[1].id) assertEquals(engineTwo.id, suggestions[1].id)
assertEquals(engineTwo.icon, suggestions[1].icon) assertEquals(engineTwo.icon, suggestions[1].icon)
assertEquals(engineTwo.name, suggestions[1].title) assertEquals(engineTwo.name, suggestions[1].title)
@ -90,16 +90,16 @@ class ShortcutsSuggestionProviderTest {
@Test @Test
fun `callbacks are triggered when suggestions are clicked`() = runBlockingTest { fun `callbacks are triggered when suggestions are clicked`() = runBlockingTest {
val engineOne = mockk<SearchEngine>(relaxed = true) val engineOne = mockk<SearchEngine>(relaxed = true)
val searchEngineProvider = mockk<FenixSearchEngineProvider> { val store = BrowserStore(BrowserState(
every { installedSearchEngines(context) } returns SearchEngineList( search = SearchState(
list = listOf(engineOne), regionSearchEngines = listOf(engineOne)
default = null
) )
} ))
val selectShortcutEngine = mockk<(SearchEngine) -> Unit>(relaxed = true) val selectShortcutEngine = mockk<(SearchEngine) -> Unit>(relaxed = true)
val selectShortcutEngineSettings = mockk<() -> Unit>(relaxed = true) val selectShortcutEngineSettings = mockk<() -> Unit>(relaxed = true)
val provider = ShortcutsSuggestionProvider( val provider = ShortcutsSuggestionProvider(
searchEngineProvider, store,
context, context,
selectShortcutEngine, selectShortcutEngine,
selectShortcutEngineSettings selectShortcutEngineSettings

@ -48,7 +48,8 @@ class ToolbarViewTest {
every { name } returns "Search Engine" every { name } returns "Search Engine"
every { icon } returns testContext.getDrawable(R.drawable.ic_search)!!.toBitmap() every { icon } returns testContext.getDrawable(R.drawable.ic_search)!!.toBitmap()
}), }),
defaultEngineSource = mockk(relaxed = true), defaultEngine = null,
showSearchShortcutsSetting = false,
showSearchSuggestionsHint = false, showSearchSuggestionsHint = false,
showSearchSuggestions = false, showSearchSuggestions = false,
showSearchShortcuts = false, showSearchShortcuts = false,

@ -78,7 +78,6 @@ open class MozillaUseLazyMonitored(config: Config) : Rule(config) {
"IntentProcessors", "IntentProcessors",
"PerformanceComponent", "PerformanceComponent",
"Push", "Push",
"Search",
"Services", "Services",
"UseCases" "UseCases"
).map { "app/src/main/java/org/mozilla/fenix/components/$it.kt" } ).map { "app/src/main/java/org/mozilla/fenix/components/$it.kt" }

Loading…
Cancel
Save