For #3633 - Refactors the search screen to use lib-state
* For #3633 - Adds SearchStore * For #3633 - Refactors AwesomeBarUIView * For #3633 - Refactors ToolbarUIView to use lib-state * For #3633 - Fixes a couple of state bugs * For #3633 - Moves all user interaction to SearchInteractor * For #3633 - Adds kdocs to SearchStore and SearchInteractor * For #3633 - Adds documentation for the properties on SearchState Also removes uneccessary property * For #3633 - Creates `StateViewModel` to handle state restoration * For #3633 - Adds a test for onTextChanged * For #3633 - Adds tests for SearchInteractor * For #3633 - Fixes bugs and adds documentationmaster
parent
6b639b1a32
commit
e4ff70c542
|
@ -0,0 +1,35 @@
|
|||
/* 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 androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.lifecycle.get
|
||||
import mozilla.components.lib.state.State
|
||||
|
||||
/**
|
||||
* Generic ViewModel to wrap a State object for state restoration
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class StateViewModel<T : State>(initialState: T) : ViewModel() {
|
||||
var state: T = initialState
|
||||
private set(value) { field = value }
|
||||
|
||||
fun update(state: T) { this.state = state }
|
||||
|
||||
companion object {
|
||||
fun <S : State> get(fragment: Fragment, initialState: S): StateViewModel<S> {
|
||||
val factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return StateViewModel(initialState) as T
|
||||
}
|
||||
}
|
||||
|
||||
return ViewModelProviders.of(fragment, factory).get()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,11 +17,13 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import mozilla.components.browser.search.SearchEngineManager
|
||||
import mozilla.components.support.base.log.Log
|
||||
import mozilla.components.support.base.log.Log.Priority.WARN
|
||||
import org.mozilla.fenix.FenixApplication
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.Components
|
||||
import org.mozilla.fenix.components.metrics.MetricController
|
||||
|
||||
/**
|
||||
* Get the BrowserApplication object from a context.
|
||||
|
@ -35,6 +37,18 @@ val Context.application: FenixApplication
|
|||
val Context.components: Components
|
||||
get() = application.components
|
||||
|
||||
/**
|
||||
* Helper function to get the MetricController off of context.
|
||||
*/
|
||||
val Context.metrics: MetricController
|
||||
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
|
||||
?: this as? Activity
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.content.Context
|
|||
import android.content.DialogInterface
|
||||
import android.graphics.Typeface.BOLD
|
||||
import android.graphics.Typeface.ITALIC
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.LayoutInflater
|
||||
|
@ -16,59 +17,45 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import kotlinx.android.synthetic.main.fragment_search.view.*
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import mozilla.components.concept.storage.HistoryStorage
|
||||
import mozilla.components.feature.qr.QrFeature
|
||||
import mozilla.components.lib.state.Store
|
||||
import mozilla.components.lib.state.ext.observe
|
||||
import mozilla.components.support.base.feature.BackHandler
|
||||
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
|
||||
import mozilla.components.support.ktx.android.content.hasCamera
|
||||
import mozilla.components.support.ktx.android.content.isPermissionGranted
|
||||
import mozilla.components.support.ktx.kotlin.isUrl
|
||||
import org.jetbrains.anko.backgroundDrawable
|
||||
import org.mozilla.fenix.BrowserDirection
|
||||
import org.mozilla.fenix.FenixViewModelProvider
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ThemeManager
|
||||
import org.mozilla.fenix.components.StateViewModel
|
||||
import org.mozilla.fenix.components.metrics.Event
|
||||
import org.mozilla.fenix.components.toolbar.SearchAction
|
||||
import org.mozilla.fenix.components.toolbar.SearchChange
|
||||
import org.mozilla.fenix.components.toolbar.SearchState
|
||||
import org.mozilla.fenix.components.toolbar.ToolbarComponent
|
||||
import org.mozilla.fenix.components.toolbar.ToolbarUIView
|
||||
import org.mozilla.fenix.components.toolbar.ToolbarViewModel
|
||||
import org.mozilla.fenix.ext.getSpannable
|
||||
import org.mozilla.fenix.ext.requireComponents
|
||||
import org.mozilla.fenix.mvi.ActionBusFactory
|
||||
import org.mozilla.fenix.mvi.getAutoDisposeObservable
|
||||
import org.mozilla.fenix.mvi.getManagedEmitter
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarAction
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarChange
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarComponent
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarState
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarUIView
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarViewModel
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarView
|
||||
import org.mozilla.fenix.search.toolbar.ToolbarView
|
||||
import org.mozilla.fenix.utils.Settings
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
class SearchFragment : Fragment(), BackHandler {
|
||||
private lateinit var toolbarComponent: ToolbarComponent
|
||||
private lateinit var awesomeBarComponent: AwesomeBarComponent
|
||||
private var sessionId: String? = null
|
||||
private var isPrivate = false
|
||||
private lateinit var toolbarView: ToolbarView
|
||||
private lateinit var awesomeBarView: AwesomeBarView
|
||||
private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
|
||||
private var permissionDidUpdate = false
|
||||
private lateinit var searchStore: SearchStore
|
||||
private lateinit var searchInteractor: SearchInteractor
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Disabled while awaiting a better solution to #3209
|
||||
// postponeEnterTransition()
|
||||
// sharedElementEnterTransition =
|
||||
// TransitionInflater.from(context).inflateTransition(android.R.transition.move).setDuration(
|
||||
// SHARED_TRANSITION_MS
|
||||
// )
|
||||
|
||||
requireComponents.analytics.metrics.track(Event.InteractWithSearchURLArea)
|
||||
}
|
||||
|
||||
|
@ -77,43 +64,44 @@ class SearchFragment : Fragment(), BackHandler {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
sessionId = SearchFragmentArgs.fromBundle(arguments!!).sessionId
|
||||
isPrivate = (activity as HomeActivity).browsingModeManager.isPrivate
|
||||
val session = arguments
|
||||
?.let(SearchFragmentArgs.Companion::fromBundle)
|
||||
?.let { it.sessionId }
|
||||
?.let(requireComponents.core.sessionManager::findSessionById)
|
||||
|
||||
val session = sessionId?.let { requireComponents.core.sessionManager.findSessionById(it) }
|
||||
val view = inflater.inflate(R.layout.fragment_search, container, false)
|
||||
val url = session?.url ?: ""
|
||||
|
||||
toolbarComponent = ToolbarComponent(
|
||||
view.toolbar_component_wrapper,
|
||||
ActionBusFactory.get(this),
|
||||
sessionId,
|
||||
isPrivate,
|
||||
true,
|
||||
view.search_engine_icon,
|
||||
FenixViewModelProvider.create(
|
||||
val viewModel = StateViewModel.get(
|
||||
this,
|
||||
ToolbarViewModel::class.java
|
||||
) {
|
||||
ToolbarViewModel(SearchState(url, session?.searchTerms ?: "", isEditing = true))
|
||||
}
|
||||
).also {
|
||||
// Remove background from toolbar view since it conflicts with the search UI.
|
||||
it.uiView.view.background = null
|
||||
it.uiView.view.layoutParams.height = CoordinatorLayout.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
awesomeBarComponent = AwesomeBarComponent(
|
||||
view.search_layout,
|
||||
ActionBusFactory.get(this),
|
||||
FenixViewModelProvider.create(
|
||||
this,
|
||||
AwesomeBarViewModel::class.java
|
||||
) {
|
||||
AwesomeBarViewModel(AwesomeBarState("", false))
|
||||
}
|
||||
SearchState(
|
||||
query = url,
|
||||
showShortcutEnginePicker = false,
|
||||
searchEngineSource = SearchEngineSource.Default(
|
||||
requireComponents.search.searchEngineManager.getDefaultSearchEngine(requireContext())
|
||||
),
|
||||
showSuggestions = Settings.getInstance(requireContext()).showSearchSuggestions,
|
||||
showVisitedSitesBookmarks = Settings.getInstance(requireContext()).shouldShowVisitedSitesBookmarks,
|
||||
session = session
|
||||
)
|
||||
ActionBusFactory.get(this).logMergedObservables()
|
||||
)
|
||||
|
||||
searchStore = Store(
|
||||
viewModel.state,
|
||||
::searchStateReducer
|
||||
)
|
||||
|
||||
searchStore.observe(this) { viewModel.update(it) }
|
||||
|
||||
searchInteractor = SearchInteractor(
|
||||
activity as HomeActivity,
|
||||
findNavController(),
|
||||
searchStore
|
||||
)
|
||||
|
||||
toolbarView = ToolbarView(view.toolbar_component_wrapper, searchInteractor, historyStorageProvider())
|
||||
awesomeBarView = AwesomeBarView(view.search_layout, searchInteractor)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
|
@ -151,7 +139,7 @@ class SearchFragment : Fragment(), BackHandler {
|
|||
(activity as HomeActivity)
|
||||
.openToBrowserAndLoad(
|
||||
searchTermOrURL = result,
|
||||
newTab = sessionId == null,
|
||||
newTab = searchStore.state.session == null,
|
||||
from = BrowserDirection.FromSearch
|
||||
)
|
||||
dialog.dismiss()
|
||||
|
@ -166,19 +154,16 @@ class SearchFragment : Fragment(), BackHandler {
|
|||
)
|
||||
|
||||
view.search_scan_button.setOnClickListener {
|
||||
getManagedEmitter<SearchChange>().onNext(SearchChange.ToolbarClearedFocus)
|
||||
toolbarView.view.clearFocus()
|
||||
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
|
||||
qrFeature.get()?.scan(R.id.container)
|
||||
}
|
||||
|
||||
lifecycle.addObserver((toolbarComponent.uiView as ToolbarUIView).toolbarIntegration)
|
||||
|
||||
view.toolbar_wrapper.clipToOutline = false
|
||||
|
||||
search_shortcuts_button.setOnClickListener {
|
||||
val isOpen = (awesomeBarComponent.uiView as AwesomeBarUIView).state?.showShortcutEnginePicker ?: false
|
||||
|
||||
getManagedEmitter<AwesomeBarChange>().onNext(AwesomeBarChange.SearchShortcutEnginePicker(!isOpen))
|
||||
val isOpen = searchStore.state.showShortcutEnginePicker
|
||||
searchStore.dispatch(SearchAction.SearchShortcutEnginePicker(!isOpen))
|
||||
|
||||
if (isOpen) {
|
||||
requireComponents.analytics.metrics.track(Event.SearchShortcutMenuClosed)
|
||||
|
@ -187,124 +172,72 @@ class SearchFragment : Fragment(), BackHandler {
|
|||
}
|
||||
}
|
||||
|
||||
searchStore.observe(view) {
|
||||
MainScope().launch {
|
||||
awesomeBarView.update(it)
|
||||
toolbarView.update(it)
|
||||
updateSearchEngineIcon(it)
|
||||
updateSearchShortuctsIcon(it)
|
||||
updateSearchWithLabel(it)
|
||||
}
|
||||
}
|
||||
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
subscribeToSearchActions()
|
||||
subscribeToAwesomeBarActions()
|
||||
|
||||
if (!permissionDidUpdate) {
|
||||
getManagedEmitter<SearchChange>().onNext(SearchChange.ToolbarRequestedFocus)
|
||||
toolbarView.view.requestFocus()
|
||||
}
|
||||
|
||||
permissionDidUpdate = false
|
||||
(activity as AppCompatActivity).supportActionBar?.hide()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
getManagedEmitter<SearchChange>().onNext(SearchChange.ToolbarClearedFocus)
|
||||
toolbarView.view.clearFocus()
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return when {
|
||||
qrFeature.onBackPressed() -> {
|
||||
view?.search_scan_button?.isChecked = false
|
||||
getManagedEmitter<SearchChange>().onNext(SearchChange.ToolbarRequestedFocus)
|
||||
toolbarView.view.requestFocus()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToSearchActions() {
|
||||
getAutoDisposeObservable<SearchAction>()
|
||||
.subscribe {
|
||||
when (it) {
|
||||
is SearchAction.UrlCommitted -> {
|
||||
if (it.url.isNotBlank()) {
|
||||
(activity as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = it.url,
|
||||
newTab = sessionId == null,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = it.engine
|
||||
private fun updateSearchEngineIcon(searchState: SearchState) {
|
||||
val searchIcon = searchState.searchEngineSource.searchEngine.icon
|
||||
val draw = BitmapDrawable(resources, searchIcon)
|
||||
val iconSize = resources.getDimension(R.dimen.preference_icon_drawable_size).toInt()
|
||||
draw.setBounds(0, 0, iconSize, iconSize)
|
||||
search_engine_icon?.backgroundDrawable = draw
|
||||
}
|
||||
|
||||
private fun updateSearchWithLabel(searchState: SearchState) {
|
||||
search_with_shortcuts.visibility = if (searchState.showShortcutEnginePicker) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun updateSearchShortuctsIcon(searchState: SearchState) {
|
||||
with(requireContext()) {
|
||||
val showShortcuts = searchState.showShortcutEnginePicker
|
||||
search_shortcuts_button?.isChecked = showShortcuts
|
||||
|
||||
val color = if (showShortcuts) R.attr.foundation else R.attr.primaryText
|
||||
|
||||
search_shortcuts_button.compoundDrawables[0]?.setTint(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(color, this)
|
||||
)
|
||||
|
||||
val event = if (it.url.isUrl()) {
|
||||
Event.EnteredUrl(false)
|
||||
} else {
|
||||
val engine = it.engine ?: requireComponents
|
||||
.search.searchEngineManager.getDefaultSearchEngine(requireContext())
|
||||
|
||||
createSearchEvent(engine, false)
|
||||
}
|
||||
|
||||
requireComponents.analytics.metrics.track(event)
|
||||
}
|
||||
}
|
||||
is SearchAction.TextChanged -> {
|
||||
getManagedEmitter<SearchChange>().onNext(SearchChange.QueryTextChanged(it.query))
|
||||
getManagedEmitter<AwesomeBarChange>().onNext(AwesomeBarChange.UpdateQuery(it.query))
|
||||
}
|
||||
is SearchAction.EditingCanceled -> {
|
||||
Navigation.findNavController(toolbar_wrapper).navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToAwesomeBarActions() {
|
||||
getAutoDisposeObservable<AwesomeBarAction>()
|
||||
.subscribe {
|
||||
when (it) {
|
||||
is AwesomeBarAction.URLTapped -> {
|
||||
(activity as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = it.url,
|
||||
newTab = sessionId == null,
|
||||
from = BrowserDirection.FromSearch
|
||||
)
|
||||
requireComponents.analytics.metrics.track(Event.EnteredUrl(false))
|
||||
}
|
||||
is AwesomeBarAction.SearchTermsTapped -> {
|
||||
(activity as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = it.searchTerms,
|
||||
newTab = sessionId == null,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = it.engine,
|
||||
forceSearch = true
|
||||
)
|
||||
|
||||
val engine = it.engine ?: requireComponents
|
||||
.search.searchEngineManager.getDefaultSearchEngine(requireContext())
|
||||
val event = createSearchEvent(engine, true)
|
||||
|
||||
requireComponents.analytics.metrics.track(event)
|
||||
}
|
||||
is AwesomeBarAction.SearchShortcutEngineSelected -> {
|
||||
getManagedEmitter<AwesomeBarChange>()
|
||||
.onNext(AwesomeBarChange.SearchShortcutEngineSelected(it.engine))
|
||||
getManagedEmitter<SearchChange>()
|
||||
.onNext(SearchChange.SearchShortcutEngineSelected(it.engine))
|
||||
|
||||
requireComponents.analytics.metrics.track(Event.SearchShortcutSelected(it.engine.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSearchEvent(engine: SearchEngine, isSuggestion: Boolean): Event.PerformedSearch {
|
||||
val isShortcut = engine != requireComponents.search.searchEngineManager.defaultSearchEngine
|
||||
|
||||
val engineSource =
|
||||
if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine)
|
||||
else Event.PerformedSearch.EngineSource.Default(engine)
|
||||
|
||||
val source =
|
||||
if (isSuggestion) Event.PerformedSearch.EventSource.Suggestion(engineSource)
|
||||
else Event.PerformedSearch.EventSource.Action(engineSource)
|
||||
|
||||
return Event.PerformedSearch(source)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
|
@ -324,8 +257,13 @@ class SearchFragment : Fragment(), BackHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun historyStorageProvider(): HistoryStorage? {
|
||||
return if (Settings.getInstance(requireContext()).shouldShowVisitedSitesBookmarks) {
|
||||
requireComponents.core.historyStorage
|
||||
} else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHARED_TRANSITION_MS = 150L
|
||||
private const val REQUEST_CODE_CAMERA_PERMISSIONS = 1
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
/* 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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.navigation.NavController
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.support.ktx.kotlin.isUrl
|
||||
import org.mozilla.fenix.BrowserDirection
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.components.metrics.Event
|
||||
import org.mozilla.fenix.ext.metrics
|
||||
import org.mozilla.fenix.ext.searchEngineManager
|
||||
import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor
|
||||
import org.mozilla.fenix.search.toolbar.ToolbarInteractor
|
||||
|
||||
/**
|
||||
* Interactor for the search screen
|
||||
* Provides implementations for the AwesomeBarView and ToolbarView
|
||||
*/
|
||||
class SearchInteractor(
|
||||
private val context: Context,
|
||||
private val navController: NavController,
|
||||
private val store: SearchStore
|
||||
) : AwesomeBarInteractor, ToolbarInteractor {
|
||||
override fun onUrlCommitted(url: String) {
|
||||
if (url.isNotBlank()) {
|
||||
(context as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = url,
|
||||
newTab = store.state.session == null,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = store.state.searchEngineSource.searchEngine
|
||||
)
|
||||
|
||||
val event = if (url.isUrl()) {
|
||||
Event.EnteredUrl(false)
|
||||
} else {
|
||||
createSearchEvent(store.state.searchEngineSource.searchEngine, false)
|
||||
}
|
||||
|
||||
context.metrics.track(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditingCanceled() {
|
||||
navController.navigateUp()
|
||||
}
|
||||
|
||||
override fun onTextChanged(text: String) {
|
||||
store.dispatch(SearchAction.UpdateQuery(text))
|
||||
}
|
||||
|
||||
override fun onUrlTapped(url: String) {
|
||||
(context as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = url,
|
||||
newTab = store.state.session == null,
|
||||
from = BrowserDirection.FromSearch
|
||||
)
|
||||
|
||||
context.metrics.track(Event.EnteredUrl(false))
|
||||
}
|
||||
|
||||
override fun onSearchTermsTapped(searchTerms: String) {
|
||||
(context as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = searchTerms,
|
||||
newTab = store.state.session == null,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = store.state.searchEngineSource.searchEngine,
|
||||
forceSearch = true
|
||||
)
|
||||
|
||||
val event = createSearchEvent(store.state.searchEngineSource.searchEngine, true)
|
||||
context.metrics.track(event)
|
||||
}
|
||||
|
||||
override fun onSearchShortcutEngineSelected(searchEngine: SearchEngine) {
|
||||
store.dispatch(SearchAction.SearchShortcutEngineSelected(searchEngine))
|
||||
context.metrics.track(Event.SearchShortcutSelected(searchEngine.name))
|
||||
}
|
||||
|
||||
override fun onClickSearchEngineSettings() {
|
||||
val directions = SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment()
|
||||
navController.navigate(directions)
|
||||
}
|
||||
|
||||
private fun createSearchEvent(engine: SearchEngine, isSuggestion: Boolean): Event.PerformedSearch {
|
||||
val isShortcut = engine != context.searchEngineManager.defaultSearchEngine
|
||||
|
||||
val engineSource =
|
||||
if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine)
|
||||
else Event.PerformedSearch.EngineSource.Default(engine)
|
||||
|
||||
val source =
|
||||
if (isSuggestion) Event.PerformedSearch.EventSource.Suggestion(engineSource)
|
||||
else Event.PerformedSearch.EventSource.Action(engineSource)
|
||||
|
||||
return Event.PerformedSearch(source)
|
||||
}
|
||||
}
|
|
@ -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.search
|
||||
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.browser.session.Session
|
||||
import mozilla.components.lib.state.Action
|
||||
import mozilla.components.lib.state.State
|
||||
import mozilla.components.lib.state.Store
|
||||
|
||||
/**
|
||||
* An alias to make it easier to work with `Store<SearchState, SearchAction>`
|
||||
*/
|
||||
typealias SearchStore = Store<SearchState, SearchAction>
|
||||
|
||||
/**
|
||||
* Wraps a `SearchEngine` to give consumers the context that it was selected as a shortcut
|
||||
*/
|
||||
sealed class SearchEngineSource {
|
||||
abstract val searchEngine: SearchEngine
|
||||
|
||||
data class Default(override val searchEngine: SearchEngine) : SearchEngineSource()
|
||||
data class Shortcut(override val searchEngine: SearchEngine) : SearchEngineSource()
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for the Search Screen
|
||||
* @property query The current search query string
|
||||
* @property showShortcutEnginePicker Whether or not to show the available search engine view
|
||||
* @property searchEngineSource The current selected search engine with the context of how it was selected
|
||||
* @property showSuggestions Whether or not to show search suggestions for the selected search engine in the AwesomeBar
|
||||
* @property showVisitedSitesBookmarks Whether or not to show history and bookmark suggestions in the AwesomeBar
|
||||
* @property session The current session if available
|
||||
*/
|
||||
data class SearchState(
|
||||
val query: String,
|
||||
val showShortcutEnginePicker: Boolean,
|
||||
val searchEngineSource: SearchEngineSource,
|
||||
val showSuggestions: Boolean,
|
||||
val showVisitedSitesBookmarks: Boolean,
|
||||
val session: Session?
|
||||
) : State
|
||||
|
||||
/**
|
||||
* Actions to dispatch through the `SearchStore` to modify `SearchState` through the reducer.
|
||||
*/
|
||||
sealed class SearchAction : Action {
|
||||
data class SearchShortcutEngineSelected(val engine: SearchEngine) : SearchAction()
|
||||
data class SearchShortcutEnginePicker(val show: Boolean) : SearchAction()
|
||||
data class UpdateQuery(val query: String) : SearchAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* The SearchState Reducer.
|
||||
*/
|
||||
fun searchStateReducer(state: SearchState, action: SearchAction): SearchState {
|
||||
return when (action) {
|
||||
is SearchAction.SearchShortcutEngineSelected ->
|
||||
state.copy(
|
||||
searchEngineSource = SearchEngineSource.Shortcut(action.engine),
|
||||
showShortcutEnginePicker = false
|
||||
)
|
||||
is SearchAction.SearchShortcutEnginePicker ->
|
||||
state.copy(showShortcutEnginePicker = action.show)
|
||||
is SearchAction.UpdateQuery ->
|
||||
state.copy(query = action.query)
|
||||
}
|
||||
}
|
|
@ -1,66 +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.awesomebar
|
||||
|
||||
import android.view.ViewGroup
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import org.mozilla.fenix.mvi.ViewState
|
||||
import org.mozilla.fenix.mvi.Change
|
||||
import org.mozilla.fenix.mvi.Action
|
||||
import org.mozilla.fenix.mvi.ActionBusFactory
|
||||
import org.mozilla.fenix.mvi.Reducer
|
||||
import org.mozilla.fenix.mvi.UIComponent
|
||||
import org.mozilla.fenix.mvi.UIComponentViewModelBase
|
||||
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
|
||||
|
||||
data class AwesomeBarState(
|
||||
val query: String,
|
||||
val showShortcutEnginePicker: Boolean,
|
||||
val suggestionEngine: SearchEngine? = null
|
||||
) : ViewState
|
||||
|
||||
sealed class AwesomeBarAction : Action {
|
||||
data class URLTapped(val url: String) : AwesomeBarAction()
|
||||
data class SearchTermsTapped(val searchTerms: String, val engine: SearchEngine? = null) : AwesomeBarAction()
|
||||
data class SearchShortcutEngineSelected(val engine: SearchEngine) : AwesomeBarAction()
|
||||
}
|
||||
|
||||
sealed class AwesomeBarChange : Change {
|
||||
data class SearchShortcutEngineSelected(val engine: SearchEngine) : AwesomeBarChange()
|
||||
data class SearchShortcutEnginePicker(val show: Boolean) : AwesomeBarChange()
|
||||
data class UpdateQuery(val query: String) : AwesomeBarChange()
|
||||
}
|
||||
|
||||
class AwesomeBarComponent(
|
||||
private val container: ViewGroup,
|
||||
bus: ActionBusFactory,
|
||||
viewModelProvider: UIComponentViewModelProvider<AwesomeBarState, AwesomeBarChange>
|
||||
) : UIComponent<AwesomeBarState, AwesomeBarAction, AwesomeBarChange>(
|
||||
bus.getManagedEmitter(AwesomeBarAction::class.java),
|
||||
bus.getSafeManagedObservable(AwesomeBarChange::class.java),
|
||||
viewModelProvider
|
||||
) {
|
||||
override fun initView() = AwesomeBarUIView(container, actionEmitter, changesObservable)
|
||||
|
||||
init {
|
||||
bind()
|
||||
}
|
||||
}
|
||||
|
||||
class AwesomeBarViewModel(
|
||||
initialState: AwesomeBarState
|
||||
) : UIComponentViewModelBase<AwesomeBarState, AwesomeBarChange>(initialState, reducer) {
|
||||
companion object {
|
||||
val reducer: Reducer<AwesomeBarState, AwesomeBarChange> = { state, change ->
|
||||
when (change) {
|
||||
is AwesomeBarChange.SearchShortcutEngineSelected ->
|
||||
state.copy(suggestionEngine = change.engine, showShortcutEnginePicker = false)
|
||||
is AwesomeBarChange.SearchShortcutEnginePicker ->
|
||||
state.copy(showShortcutEnginePicker = change.show)
|
||||
is AwesomeBarChange.UpdateQuery -> state.copy(query = change.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,219 +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.awesomebar
|
||||
|
||||
import android.graphics.PorterDuff.Mode.SRC_IN
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.functions.Consumer
|
||||
import mozilla.components.browser.awesomebar.BrowserAwesomeBar
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.concept.engine.EngineSession
|
||||
import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.ClipboardSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
|
||||
import mozilla.components.feature.search.SearchUseCases
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import mozilla.components.support.ktx.android.view.hideKeyboard
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ThemeManager
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.mvi.UIView
|
||||
import org.mozilla.fenix.utils.Settings
|
||||
|
||||
class AwesomeBarUIView(
|
||||
private val container: ViewGroup,
|
||||
actionEmitter: Observer<AwesomeBarAction>,
|
||||
changesObservable: Observable<AwesomeBarChange>
|
||||
) :
|
||||
UIView<AwesomeBarState, AwesomeBarAction, AwesomeBarChange>(
|
||||
container,
|
||||
actionEmitter,
|
||||
changesObservable
|
||||
) {
|
||||
override val view: BrowserAwesomeBar = LayoutInflater.from(container.context)
|
||||
.inflate(R.layout.component_awesomebar, container, true)
|
||||
.findViewById(R.id.awesomeBar)
|
||||
|
||||
var state: AwesomeBarState? = null
|
||||
private set
|
||||
|
||||
private var clipboardSuggestionProvider: ClipboardSuggestionProvider? = null
|
||||
private var sessionProvider: SessionSuggestionProvider? = null
|
||||
private var historyStorageProvider: HistoryStorageSuggestionProvider? = null
|
||||
private var shortcutsEnginePickerProvider: ShortcutsSuggestionProvider? = null
|
||||
private var bookmarksStorageSuggestionProvider: BookmarksStorageSuggestionProvider? = null
|
||||
|
||||
private val searchSuggestionProvider: SearchSuggestionProvider?
|
||||
get() = searchSuggestionFromShortcutProvider ?: defaultSearchSuggestionProvider!!
|
||||
|
||||
private var defaultSearchSuggestionProvider: SearchSuggestionProvider? = null
|
||||
private var searchSuggestionFromShortcutProvider: SearchSuggestionProvider? = null
|
||||
|
||||
private val shortcutEngineManager by lazy {
|
||||
ShortcutEngineManager(
|
||||
this,
|
||||
actionEmitter,
|
||||
::setShortcutEngine,
|
||||
::showSuggestionProviders,
|
||||
::showSearchSuggestionProvider
|
||||
)
|
||||
}
|
||||
|
||||
private val loadUrlUseCase = object : SessionUseCases.LoadUrlUseCase {
|
||||
override fun invoke(url: String, flags: EngineSession.LoadUrlFlags) {
|
||||
actionEmitter.onNext(AwesomeBarAction.URLTapped(url))
|
||||
}
|
||||
}
|
||||
|
||||
private val searchUseCase = object : SearchUseCases.SearchUseCase {
|
||||
override fun invoke(searchTerms: String, searchEngine: SearchEngine?) {
|
||||
actionEmitter.onNext(AwesomeBarAction.SearchTermsTapped(searchTerms, searchEngine))
|
||||
}
|
||||
}
|
||||
|
||||
private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase {
|
||||
override fun invoke(searchTerms: String, searchEngine: SearchEngine?) {
|
||||
actionEmitter.onNext(
|
||||
AwesomeBarAction.SearchTermsTapped(
|
||||
searchTerms,
|
||||
state?.suggestionEngine
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
with(container.context) {
|
||||
val primaryTextColor = ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, this)
|
||||
)
|
||||
|
||||
val draw = getDrawable(R.drawable.ic_link)
|
||||
draw?.setColorFilter(primaryTextColor, SRC_IN)
|
||||
clipboardSuggestionProvider = ClipboardSuggestionProvider(
|
||||
this,
|
||||
loadUrlUseCase,
|
||||
draw!!.toBitmap(),
|
||||
getString(R.string.awesomebar_clipboard_title)
|
||||
)
|
||||
|
||||
sessionProvider =
|
||||
SessionSuggestionProvider(
|
||||
components.core.sessionManager,
|
||||
components.useCases.tabsUseCases.selectTab,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
historyStorageProvider =
|
||||
HistoryStorageSuggestionProvider(
|
||||
components.core.historyStorage,
|
||||
loadUrlUseCase,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
bookmarksStorageSuggestionProvider =
|
||||
BookmarksStorageSuggestionProvider(
|
||||
components.core.bookmarksStorage,
|
||||
loadUrlUseCase,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
if (Settings.getInstance(container.context).showSearchSuggestions) {
|
||||
val searchDrawable = getDrawable(R.drawable.ic_search)
|
||||
searchDrawable?.setColorFilter(primaryTextColor, SRC_IN)
|
||||
defaultSearchSuggestionProvider =
|
||||
SearchSuggestionProvider(
|
||||
searchEngine = components.search.searchEngineManager.getDefaultSearchEngine(
|
||||
this
|
||||
),
|
||||
searchUseCase = searchUseCase,
|
||||
fetchClient = components.core.client,
|
||||
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
|
||||
limit = 3,
|
||||
icon = searchDrawable?.toBitmap()
|
||||
)
|
||||
}
|
||||
|
||||
shortcutsEnginePickerProvider =
|
||||
ShortcutsSuggestionProvider(
|
||||
components.search.searchEngineManager,
|
||||
this,
|
||||
shortcutEngineManager::selectShortcutEngine,
|
||||
shortcutEngineManager::selectShortcutEngineSettings
|
||||
)
|
||||
|
||||
shortcutEngineManager.shortcutsEnginePickerProvider = shortcutsEnginePickerProvider
|
||||
|
||||
val listener = object : RecyclerView.OnFlingListener() {
|
||||
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
|
||||
view.hideKeyboard()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
view.onFlingListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSuggestionProviders() {
|
||||
if (Settings.getInstance(container.context).showSearchSuggestions) {
|
||||
view.addProviders(searchSuggestionProvider!!)
|
||||
}
|
||||
|
||||
if (Settings.getInstance(container.context).shouldShowVisitedSitesBookmarks) {
|
||||
view.addProviders(bookmarksStorageSuggestionProvider!!)
|
||||
view.addProviders(historyStorageProvider!!)
|
||||
}
|
||||
|
||||
view.addProviders(
|
||||
clipboardSuggestionProvider!!,
|
||||
sessionProvider!!
|
||||
)
|
||||
}
|
||||
|
||||
private fun showSearchSuggestionProvider() {
|
||||
if (Settings.getInstance(container.context).showSearchSuggestions) {
|
||||
view.addProviders(searchSuggestionProvider!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setShortcutEngine(engine: SearchEngine) {
|
||||
with(container.context) {
|
||||
val draw = getDrawable(R.drawable.ic_search)
|
||||
draw?.setColorFilter(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, this)
|
||||
), SRC_IN
|
||||
)
|
||||
|
||||
searchSuggestionFromShortcutProvider =
|
||||
SearchSuggestionProvider(
|
||||
components.search.searchEngineManager.getDefaultSearchEngine(this, engine.name),
|
||||
shortcutSearchUseCase,
|
||||
components.core.client,
|
||||
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
|
||||
icon = draw?.toBitmap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateView() = Consumer<AwesomeBarState> {
|
||||
shortcutEngineManager.updateSelectedEngineIfNecessary(it)
|
||||
shortcutEngineManager.updateEnginePickerVisibilityIfNecessary(it)
|
||||
|
||||
view.onInputChanged(it.query)
|
||||
state = it
|
||||
}
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
package org.mozilla.fenix.search.awesomebar
|
||||
|
||||
/* 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/. */
|
||||
|
||||
import android.graphics.PorterDuff.Mode.SRC_IN
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.android.extensions.LayoutContainer
|
||||
import mozilla.components.browser.awesomebar.BrowserAwesomeBar
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.concept.engine.EngineSession
|
||||
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.ClipboardSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
|
||||
import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
|
||||
import mozilla.components.feature.search.SearchUseCases
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ThemeManager
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.search.SearchEngineSource
|
||||
import org.mozilla.fenix.search.SearchState
|
||||
|
||||
/**
|
||||
* Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want
|
||||
* to respond to user interaction on the AwesomebarView
|
||||
*/
|
||||
interface AwesomeBarInteractor {
|
||||
|
||||
/**
|
||||
* Called whenever a suggestion containing a URL is tapped
|
||||
* @param url the url the suggestion was providing
|
||||
*/
|
||||
fun onUrlTapped(url: String)
|
||||
|
||||
/**
|
||||
* Called whenever a search engine suggestion is tapped
|
||||
* @param searchTerms the query contained by the search suggestion
|
||||
*/
|
||||
fun onSearchTermsTapped(searchTerms: String)
|
||||
|
||||
/**
|
||||
* Called whenever a search engine shortcut is tapped
|
||||
* @param searchEngine the searchEngine that was selected
|
||||
*/
|
||||
fun onSearchShortcutEngineSelected(searchEngine: SearchEngine)
|
||||
|
||||
/**
|
||||
* Called whenever the "Search Engine Settings" item is tapped
|
||||
*/
|
||||
fun onClickSearchEngineSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* View that contains and configures the BrowserAwesomeBar
|
||||
*/
|
||||
class AwesomeBarView(
|
||||
private val container: ViewGroup,
|
||||
val interactor: AwesomeBarInteractor
|
||||
) : LayoutContainer {
|
||||
val view: BrowserAwesomeBar = LayoutInflater.from(container.context)
|
||||
.inflate(R.layout.component_awesomebar, container, true)
|
||||
.findViewById(R.id.awesomeBar)
|
||||
|
||||
override val containerView: View?
|
||||
get() = container
|
||||
|
||||
private val clipboardSuggestionProvider: ClipboardSuggestionProvider
|
||||
private val sessionProvider: SessionSuggestionProvider
|
||||
private val historyStorageProvider: HistoryStorageSuggestionProvider
|
||||
private val shortcutsEnginePickerProvider: ShortcutsSuggestionProvider
|
||||
private val bookmarksStorageSuggestionProvider: BookmarksStorageSuggestionProvider
|
||||
private val defaultSearchSuggestionProvider: SearchSuggestionProvider
|
||||
|
||||
private val loadUrlUseCase = object : SessionUseCases.LoadUrlUseCase {
|
||||
override fun invoke(url: String, flags: EngineSession.LoadUrlFlags) {
|
||||
interactor.onUrlTapped(url)
|
||||
}
|
||||
}
|
||||
|
||||
private val searchUseCase = object : SearchUseCases.SearchUseCase {
|
||||
override fun invoke(searchTerms: String, searchEngine: SearchEngine?) {
|
||||
interactor.onSearchTermsTapped(searchTerms)
|
||||
}
|
||||
}
|
||||
|
||||
private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase {
|
||||
override fun invoke(searchTerms: String, searchEngine: SearchEngine?) {
|
||||
interactor.onSearchTermsTapped(searchTerms)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
with(container.context) {
|
||||
val primaryTextColor = ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, this)
|
||||
)
|
||||
|
||||
val draw = getDrawable(R.drawable.ic_link)!!
|
||||
draw.setColorFilter(primaryTextColor, SRC_IN)
|
||||
|
||||
clipboardSuggestionProvider = ClipboardSuggestionProvider(
|
||||
this,
|
||||
loadUrlUseCase,
|
||||
draw.toBitmap(),
|
||||
getString(R.string.awesomebar_clipboard_title)
|
||||
)
|
||||
|
||||
sessionProvider =
|
||||
SessionSuggestionProvider(
|
||||
components.core.sessionManager,
|
||||
components.useCases.tabsUseCases.selectTab,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
historyStorageProvider =
|
||||
HistoryStorageSuggestionProvider(
|
||||
components.core.historyStorage,
|
||||
loadUrlUseCase,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
bookmarksStorageSuggestionProvider =
|
||||
BookmarksStorageSuggestionProvider(
|
||||
components.core.bookmarksStorage,
|
||||
loadUrlUseCase,
|
||||
components.core.icons
|
||||
)
|
||||
|
||||
val searchDrawable = getDrawable(R.drawable.ic_search)!!
|
||||
searchDrawable.setColorFilter(primaryTextColor, SRC_IN)
|
||||
|
||||
defaultSearchSuggestionProvider =
|
||||
SearchSuggestionProvider(
|
||||
searchEngine = components.search.searchEngineManager.getDefaultSearchEngine(
|
||||
this
|
||||
),
|
||||
searchUseCase = searchUseCase,
|
||||
fetchClient = components.core.client,
|
||||
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
|
||||
limit = 3,
|
||||
icon = searchDrawable.toBitmap()
|
||||
)
|
||||
|
||||
shortcutsEnginePickerProvider =
|
||||
ShortcutsSuggestionProvider(
|
||||
components.search.searchEngineManager,
|
||||
this,
|
||||
interactor::onSearchShortcutEngineSelected,
|
||||
interactor::onClickSearchEngineSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(state: SearchState) {
|
||||
view.removeAllProviders()
|
||||
|
||||
if (state.showShortcutEnginePicker) {
|
||||
view.addProviders(shortcutsEnginePickerProvider)
|
||||
} else {
|
||||
if (state.showSuggestions) {
|
||||
view.addProviders(when (state.searchEngineSource) {
|
||||
is SearchEngineSource.Default -> defaultSearchSuggestionProvider
|
||||
is SearchEngineSource.Shortcut -> createSuggestionProviderForEngine(
|
||||
state.searchEngineSource.searchEngine
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (state.showVisitedSitesBookmarks) {
|
||||
view.addProviders(bookmarksStorageSuggestionProvider, historyStorageProvider)
|
||||
}
|
||||
|
||||
view.addProviders(clipboardSuggestionProvider, sessionProvider)
|
||||
}
|
||||
|
||||
view.onInputChanged(state.query)
|
||||
}
|
||||
|
||||
private fun createSuggestionProviderForEngine(engine: SearchEngine): SearchSuggestionProvider {
|
||||
return with(container.context) {
|
||||
val draw = getDrawable(R.drawable.ic_search)
|
||||
draw?.setColorFilter(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, this)
|
||||
), SRC_IN
|
||||
)
|
||||
|
||||
SearchSuggestionProvider(
|
||||
components.search.searchEngineManager.getDefaultSearchEngine(this, engine.name),
|
||||
shortcutSearchUseCase,
|
||||
components.core.client,
|
||||
limit = 3,
|
||||
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
|
||||
icon = draw?.toBitmap()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,99 +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.awesomebar
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.Navigation
|
||||
import io.reactivex.Observer
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ThemeManager
|
||||
import org.mozilla.fenix.search.SearchFragmentDirections
|
||||
|
||||
class ShortcutEngineManager(
|
||||
private val awesomeBarUIView: AwesomeBarUIView,
|
||||
private val actionEmitter: Observer<AwesomeBarAction>,
|
||||
private val setShortcutEngine: (newEngine: SearchEngine) -> Unit,
|
||||
private val showSuggestionProviders: () -> Unit,
|
||||
private val showSearchSuggestionProvider: () -> Unit
|
||||
) {
|
||||
|
||||
var shortcutsEnginePickerProvider: ShortcutsSuggestionProvider? = null
|
||||
val context = awesomeBarUIView.containerView?.context!!
|
||||
|
||||
fun updateSelectedEngineIfNecessary(newState: AwesomeBarState) {
|
||||
if (engineDidChange(newState)) {
|
||||
newState.suggestionEngine?.let { newEngine ->
|
||||
setShortcutEngine(newEngine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEnginePickerVisibilityIfNecessary(newState: AwesomeBarState) {
|
||||
if (shouldUpdateShortcutEnginePickerVisibility(newState)) {
|
||||
if (newState.showShortcutEnginePicker) {
|
||||
showShortcutEnginePicker()
|
||||
updateSearchWithVisibility(true)
|
||||
} else {
|
||||
hideShortcutEnginePicker()
|
||||
updateSearchWithVisibility(false)
|
||||
newState.suggestionEngine?.also { showSearchSuggestionProvider() } ?: showSuggestionProviders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectShortcutEngine(engine: SearchEngine) {
|
||||
actionEmitter.onNext(AwesomeBarAction.SearchShortcutEngineSelected(engine))
|
||||
}
|
||||
|
||||
fun selectShortcutEngineSettings() {
|
||||
val directions = SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment()
|
||||
Navigation.findNavController(awesomeBarUIView.view).navigate(directions)
|
||||
}
|
||||
|
||||
private fun engineDidChange(newState: AwesomeBarState): Boolean {
|
||||
return awesomeBarUIView.state?.suggestionEngine != newState.suggestionEngine
|
||||
}
|
||||
|
||||
private fun shouldUpdateShortcutEnginePickerVisibility(newState: AwesomeBarState): Boolean {
|
||||
return awesomeBarUIView.state?.showShortcutEnginePicker != newState.showShortcutEnginePicker
|
||||
}
|
||||
|
||||
private fun showShortcutEnginePicker() {
|
||||
with(context) {
|
||||
awesomeBarUIView.search_shortcuts_button?.isChecked = true
|
||||
|
||||
awesomeBarUIView.search_shortcuts_button.compoundDrawables[0]?.setTint(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.foundation, this)
|
||||
)
|
||||
)
|
||||
|
||||
awesomeBarUIView.view.removeAllProviders()
|
||||
awesomeBarUIView.view.addProviders(shortcutsEnginePickerProvider!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideShortcutEnginePicker() {
|
||||
with(context) {
|
||||
awesomeBarUIView.search_shortcuts_button?.isChecked = false
|
||||
awesomeBarUIView.search_shortcuts_button.compoundDrawables[0]?.setTint(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, this)
|
||||
)
|
||||
)
|
||||
|
||||
awesomeBarUIView.view.removeProviders(shortcutsEnginePickerProvider!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSearchWithVisibility(visible: Boolean) {
|
||||
awesomeBarUIView.search_with_shortcuts.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
/* 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.toolbar
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.android.extensions.LayoutContainer
|
||||
import mozilla.components.browser.domains.autocomplete.ShippedDomainsProvider
|
||||
import mozilla.components.browser.toolbar.BrowserToolbar
|
||||
import mozilla.components.concept.storage.HistoryStorage
|
||||
import mozilla.components.feature.toolbar.ToolbarAutocompleteFeature
|
||||
import mozilla.components.support.ktx.android.content.res.pxToDp
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ThemeManager
|
||||
import org.mozilla.fenix.search.SearchState
|
||||
|
||||
/**
|
||||
* Interface for the Toolbar Interactor. This interface is implemented by objects that want
|
||||
* to respond to user interaction on the ToolbarView
|
||||
*/
|
||||
interface ToolbarInteractor {
|
||||
|
||||
/**
|
||||
* Called when a user hits the return key while ToolbarView has focus.
|
||||
* @param url the text inside the ToolbarView when committed
|
||||
*/
|
||||
fun onUrlCommitted(url: String)
|
||||
|
||||
/**
|
||||
* Called when a removes focus from the ToolbarView
|
||||
*/
|
||||
fun onEditingCanceled()
|
||||
|
||||
/**
|
||||
* Called whenever the text inside the ToolbarView changes
|
||||
* @param text the current text displayed by ToolbarView
|
||||
*/
|
||||
fun onTextChanged(text: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* View that contains and configures the BrowserToolbar to only be used in its editing mode.
|
||||
*/
|
||||
class ToolbarView(
|
||||
private val container: ViewGroup,
|
||||
private val interactor: ToolbarInteractor,
|
||||
private val historyStorage: HistoryStorage?
|
||||
) : LayoutContainer {
|
||||
|
||||
override val containerView: View?
|
||||
get() = container
|
||||
|
||||
val view: BrowserToolbar = LayoutInflater.from(container.context)
|
||||
.inflate(R.layout.component_search, container, true)
|
||||
.findViewById(R.id.toolbar)
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
init {
|
||||
view.apply {
|
||||
editMode()
|
||||
|
||||
elevation = resources.pxToDp(TOOLBAR_ELEVATION_IN_DP).toFloat()
|
||||
|
||||
setOnUrlCommitListener {
|
||||
interactor.onUrlCommitted(it)
|
||||
false
|
||||
}
|
||||
|
||||
background = null
|
||||
|
||||
layoutParams.height = CoordinatorLayout.LayoutParams.MATCH_PARENT
|
||||
|
||||
hint = context.getString(R.string.search_hint)
|
||||
|
||||
textColor = ContextCompat.getColor(
|
||||
container.context,
|
||||
ThemeManager.resolveAttribute(R.attr.primaryText, container.context)
|
||||
)
|
||||
|
||||
hintColor = ContextCompat.getColor(
|
||||
container.context,
|
||||
ThemeManager.resolveAttribute(R.attr.secondaryText, container.context)
|
||||
)
|
||||
|
||||
suggestionBackgroundColor = ContextCompat.getColor(
|
||||
container.context,
|
||||
R.color.suggestion_highlight_color
|
||||
)
|
||||
|
||||
setOnEditListener(object : mozilla.components.concept.toolbar.Toolbar.OnEditListener {
|
||||
override fun onCancelEditing(): Boolean {
|
||||
interactor.onEditingCanceled()
|
||||
return false
|
||||
}
|
||||
override fun onTextChanged(text: String) {
|
||||
url = text
|
||||
this@ToolbarView.interactor.onTextChanged(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ToolbarAutocompleteFeature(view).apply {
|
||||
addDomainProvider(ShippedDomainsProvider().also { it.initialize(view.context) })
|
||||
historyStorage?.also(::addHistoryStorageProvider)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(searchState: SearchState) {
|
||||
if (!isInitialized) {
|
||||
view.url = searchState.query
|
||||
view.setSearchTerms(searchState.session?.searchTerms ?: "")
|
||||
view.editMode()
|
||||
isInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TOOLBAR_ELEVATION_IN_DP = 16
|
||||
}
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
/* 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
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.browser.search.SearchEngineManager
|
||||
import org.junit.Test
|
||||
import org.mozilla.fenix.BrowserDirection
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.ext.metrics
|
||||
import org.mozilla.fenix.ext.searchEngineManager
|
||||
|
||||
class SearchInteractorTest {
|
||||
@Test
|
||||
fun onUrlCommitted() {
|
||||
val context: HomeActivity = mockk()
|
||||
val store: SearchStore = mockk()
|
||||
val state: SearchState = mockk()
|
||||
val searchEngineManager: SearchEngineManager = mockk(relaxed = true)
|
||||
val searchEngine = SearchEngineSource.Default(mockk())
|
||||
|
||||
every { context.metrics } returns mockk(relaxed = true)
|
||||
every { context.searchEngineManager } returns searchEngineManager
|
||||
every { context.openToBrowserAndLoad(any(), any(), any(), any(), any(), any()) } just Runs
|
||||
|
||||
every { store.state } returns state
|
||||
every { state.session } returns null
|
||||
every { state.searchEngineSource } returns searchEngine
|
||||
|
||||
val interactor = SearchInteractor(context, mockk(), store)
|
||||
|
||||
interactor.onUrlCommitted("test")
|
||||
|
||||
verify {
|
||||
context.openToBrowserAndLoad(
|
||||
searchTermOrURL = "test",
|
||||
newTab = true,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = searchEngine.searchEngine
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onEditingCanceled() {
|
||||
val navController: NavController = mockk(relaxed = true)
|
||||
val interactor = SearchInteractor(mockk(), navController, mockk())
|
||||
|
||||
interactor.onEditingCanceled()
|
||||
|
||||
verify {
|
||||
navController.navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onTextChanged() {
|
||||
val store: SearchStore = mockk(relaxed = true)
|
||||
val interactor = SearchInteractor(mockk(), mockk(), store)
|
||||
|
||||
interactor.onTextChanged("test")
|
||||
|
||||
verify { store.dispatch(SearchAction.UpdateQuery("test")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onUrlTapped() {
|
||||
val context: HomeActivity = mockk()
|
||||
val store: SearchStore = mockk()
|
||||
val state: SearchState = mockk()
|
||||
|
||||
every { context.metrics } returns mockk(relaxed = true)
|
||||
every { context.openToBrowserAndLoad(any(), any(), any()) } just Runs
|
||||
|
||||
every { store.state } returns state
|
||||
every { state.session } returns null
|
||||
|
||||
val interactor = SearchInteractor(context, mockk(), store)
|
||||
|
||||
interactor.onUrlTapped("test")
|
||||
|
||||
verify {
|
||||
context.openToBrowserAndLoad(
|
||||
"test",
|
||||
true,
|
||||
BrowserDirection.FromSearch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSearchTermsTapped() {
|
||||
val context: HomeActivity = mockk()
|
||||
val store: SearchStore = mockk()
|
||||
val state: SearchState = mockk()
|
||||
val searchEngineManager: SearchEngineManager = mockk(relaxed = true)
|
||||
val searchEngine = SearchEngineSource.Default(mockk())
|
||||
|
||||
every { context.metrics } returns mockk(relaxed = true)
|
||||
every { context.searchEngineManager } returns searchEngineManager
|
||||
every { context.openToBrowserAndLoad(any(), any(), any(), any(), any(), any()) } just Runs
|
||||
|
||||
every { store.state } returns state
|
||||
every { state.session } returns null
|
||||
every { state.searchEngineSource } returns searchEngine
|
||||
|
||||
val interactor = SearchInteractor(context, mockk(), store)
|
||||
|
||||
interactor.onSearchTermsTapped("test")
|
||||
verify { context.openToBrowserAndLoad(
|
||||
searchTermOrURL = "test",
|
||||
newTab = true,
|
||||
from = BrowserDirection.FromSearch,
|
||||
engine = searchEngine.searchEngine,
|
||||
forceSearch = true
|
||||
) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSearchShortcutEngineSelected() {
|
||||
val context: HomeActivity = mockk(relaxed = true)
|
||||
|
||||
every { context.metrics } returns mockk(relaxed = true)
|
||||
|
||||
val store: SearchStore = mockk(relaxed = true)
|
||||
val interactor = SearchInteractor(context, mockk(), store)
|
||||
val searchEngine: SearchEngine = mockk(relaxed = true)
|
||||
|
||||
interactor.onSearchShortcutEngineSelected(searchEngine)
|
||||
|
||||
verify { store.dispatch(SearchAction.SearchShortcutEngineSelected(searchEngine)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onClickSearchEngineSettings() {
|
||||
val navController: NavController = mockk()
|
||||
val interactor = SearchInteractor(mockk(), navController, mockk())
|
||||
|
||||
every { navController.navigate(any() as NavDirections) } just Runs
|
||||
|
||||
interactor.onClickSearchEngineSettings()
|
||||
|
||||
verify {
|
||||
navController.navigate(SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
mock-maker-inline
|
||||
// This allows mocking final classes (classes are final by default in Kotlin)
|
|
@ -46,7 +46,7 @@ private object Versions {
|
|||
const val installreferrer = "1.0"
|
||||
|
||||
const val junit = "4.12"
|
||||
const val mockito = "2.23.0"
|
||||
const val mockito = "2.24.5"
|
||||
const val mockk = "1.9.kotlin12"
|
||||
const val glide = "4.9.0"
|
||||
const val flipper = "0.21.0"
|
||||
|
|
Loading…
Reference in New Issue