From e4ff70c5422db03843baedeffcc2f247b7bfb612 Mon Sep 17 00:00:00 2001 From: Jeff Boek Date: Fri, 12 Jul 2019 16:32:00 -0700 Subject: [PATCH] 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 documentation --- .../fenix/components/StateViewModel.kt | 35 +++ .../java/org/mozilla/fenix/ext/Context.kt | 14 + .../mozilla/fenix/search/SearchFragment.kt | 254 +++++++----------- .../mozilla/fenix/search/SearchInteractor.kt | 101 +++++++ .../org/mozilla/fenix/search/SearchStore.kt | 70 +++++ .../search/awesomebar/AwesomeBarComponent.kt | 66 ----- .../search/awesomebar/AwesomeBarUIView.kt | 219 --------------- .../fenix/search/awesomebar/AwesomeBarView.kt | 207 ++++++++++++++ .../awesomebar/ShortcutEngineManager.kt | 99 ------- .../fenix/search/toolbar/ToolbarView.kt | 126 +++++++++ .../fenix/search/SearchInteractorTest.kt | 156 +++++++++++ .../org.mockito.plugins.MockMaker | 2 + buildSrc/src/main/java/Dependencies.kt | 2 +- 13 files changed, 808 insertions(+), 543 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/components/StateViewModel.kt create mode 100644 app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/search/SearchStore.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComponent.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarUIView.kt create mode 100644 app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutEngineManager.kt create mode 100644 app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt create mode 100644 app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt create mode 100644 app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/app/src/main/java/org/mozilla/fenix/components/StateViewModel.kt b/app/src/main/java/org/mozilla/fenix/components/StateViewModel.kt new file mode 100644 index 000000000..f2bfe15fd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/StateViewModel.kt @@ -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(initialState: T) : ViewModel() { + var state: T = initialState + private set(value) { field = value } + + fun update(state: T) { this.state = state } + + companion object { + fun get(fragment: Fragment, initialState: S): StateViewModel { + val factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return StateViewModel(initialState) as T + } + } + + return ViewModelProviders.of(fragment, factory).get() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/ext/Context.kt b/app/src/main/java/org/mozilla/fenix/ext/Context.kt index 0a7f3399a..0fd04e3e5 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/Context.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/Context.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt index 6ea1b5b84..9a92eb3c3 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt @@ -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() 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( - 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)) - } + val viewModel = StateViewModel.get( + this, + 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().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().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().onNext(SearchChange.ToolbarRequestedFocus) + toolbarView.view.requestFocus() } + permissionDidUpdate = false (activity as AppCompatActivity).supportActionBar?.hide() } override fun onPause() { super.onPause() - getManagedEmitter().onNext(SearchChange.ToolbarClearedFocus) + toolbarView.view.clearFocus() } override fun onBackPressed(): Boolean { return when { qrFeature.onBackPressed() -> { view?.search_scan_button?.isChecked = false - getManagedEmitter().onNext(SearchChange.ToolbarRequestedFocus) + toolbarView.view.requestFocus() true } else -> false } } - private fun subscribeToSearchActions() { - getAutoDisposeObservable() - .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 - ) - - 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().onNext(SearchChange.QueryTextChanged(it.query)) - getManagedEmitter().onNext(AwesomeBarChange.UpdateQuery(it.query)) - } - is SearchAction.EditingCanceled -> { - Navigation.findNavController(toolbar_wrapper).navigateUp() - } - } - } + 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 subscribeToAwesomeBarActions() { - getAutoDisposeObservable() - .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() - .onNext(AwesomeBarChange.SearchShortcutEngineSelected(it.engine)) - getManagedEmitter() - .onNext(SearchChange.SearchShortcutEngineSelected(it.engine)) - - requireComponents.analytics.metrics.track(Event.SearchShortcutSelected(it.engine.name)) - } - } - } + private fun updateSearchWithLabel(searchState: SearchState) { + search_with_shortcuts.visibility = if (searchState.showShortcutEnginePicker) View.VISIBLE else View.GONE } - private fun createSearchEvent(engine: SearchEngine, isSuggestion: Boolean): Event.PerformedSearch { - val isShortcut = engine != requireComponents.search.searchEngineManager.defaultSearchEngine + private fun updateSearchShortuctsIcon(searchState: SearchState) { + with(requireContext()) { + val showShortcuts = searchState.showShortcutEnginePicker + search_shortcuts_button?.isChecked = showShortcuts - val engineSource = - if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine) - else Event.PerformedSearch.EngineSource.Default(engine) + val color = if (showShortcuts) R.attr.foundation else R.attr.primaryText - val source = - if (isSuggestion) Event.PerformedSearch.EventSource.Suggestion(engineSource) - else Event.PerformedSearch.EventSource.Action(engineSource) - - return Event.PerformedSearch(source) + search_shortcuts_button.compoundDrawables[0]?.setTint( + ContextCompat.getColor( + this, + ThemeManager.resolveAttribute(color, this) + ) + ) + } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, 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 } } diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt new file mode 100644 index 000000000..36ce341ad --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchStore.kt b/app/src/main/java/org/mozilla/fenix/search/SearchStore.kt new file mode 100644 index 000000000..ce90dd9f4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/SearchStore.kt @@ -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` + */ +typealias SearchStore = Store + +/** + * 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComponent.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComponent.kt deleted file mode 100644 index ada45dce5..000000000 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComponent.kt +++ /dev/null @@ -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 -) : UIComponent( - 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(initialState, reducer) { - companion object { - val reducer: Reducer = { 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) - } - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarUIView.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarUIView.kt deleted file mode 100644 index 13881ec4f..000000000 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarUIView.kt +++ /dev/null @@ -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, - changesObservable: Observable -) : - UIView( - 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 { - shortcutEngineManager.updateSelectedEngineIfNecessary(it) - shortcutEngineManager.updateEnginePickerVisibilityIfNecessary(it) - - view.onInputChanged(it.query) - state = it - } -} diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt new file mode 100644 index 000000000..3ff1f839c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt @@ -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() + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutEngineManager.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutEngineManager.kt deleted file mode 100644 index a7246f7d5..000000000 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutEngineManager.kt +++ /dev/null @@ -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, - 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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt new file mode 100644 index 000000000..a9c722a74 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt @@ -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 + } +} diff --git a/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt new file mode 100644 index 000000000..687988fea --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt @@ -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()) + } + } +} diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..aa8682c29 --- /dev/null +++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) \ No newline at end of file diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 7cf53560e..380e06679 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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"