diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchController.kt b/app/src/main/java/org/mozilla/fenix/search/SearchController.kt new file mode 100644 index 000000000..2f1fc0c2b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/SearchController.kt @@ -0,0 +1,141 @@ +/* + * 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.browser.session.Session +import mozilla.components.support.ktx.kotlin.isUrl +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.searchEngineManager + +/** + * An interface that handles the view manipulation of the Search, triggered by the Interactor + */ +interface SearchController { + fun handleUrlCommitted(url: String) + fun handleEditingCancelled() + fun handleTextChanged(text: String) + fun handleUrlTapped(url: String) + fun handleSearchTermsTapped(searchTerms: String) + fun handleSearchShortcutEngineSelected(searchEngine: SearchEngine) + fun handleClickSearchEngineSettings() + fun handleTurnOnStartedTyping() + fun handleExistingSessionSelected(session: Session) +} + +class DefaultSearchController( + private val context: Context, + private val store: SearchStore, + private val navController: NavController +) : SearchController { + + data class UserTypingCheck(var ranOnTextChanged: Boolean, var userHasTyped: Boolean) + + internal val userTypingCheck = UserTypingCheck(false, !store.state.showShortcutEnginePicker) + + override fun handleUrlCommitted(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 handleEditingCancelled() { + navController.navigateUp() + } + + override fun handleTextChanged(text: String) { + store.dispatch(SearchAction.UpdateQuery(text)) + + if (userTypingCheck.ranOnTextChanged && !userTypingCheck.userHasTyped) { + store.dispatch(SearchAction.ShowSearchShortcutEnginePicker(false)) + handleTurnOnStartedTyping() + } + + userTypingCheck.ranOnTextChanged = true + } + + override fun handleUrlTapped(url: String) { + (context as HomeActivity).openToBrowserAndLoad( + searchTermOrURL = url, + newTab = store.state.session == null, + from = BrowserDirection.FromSearch + ) + + context.metrics.track(Event.EnteredUrl(false)) + } + + override fun handleSearchTermsTapped(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 handleSearchShortcutEngineSelected(searchEngine: SearchEngine) { + store.dispatch(SearchAction.SearchShortcutEngineSelected(searchEngine)) + context.metrics.track(Event.SearchShortcutSelected(searchEngine.name)) + } + + override fun handleClickSearchEngineSettings() { + val directions = SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment() + navController.navigate(directions) + } + + override fun handleTurnOnStartedTyping() { + userTypingCheck.ranOnTextChanged = true + userTypingCheck.userHasTyped = true + } + + override fun handleExistingSessionSelected(session: Session) { + val directions = SearchFragmentDirections.actionSearchFragmentToBrowserFragment(null) + navController.nav(R.id.searchFragment, directions) + context.components.core.sessionManager.select(session) + } + + 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/SearchFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt index 52c86eda8..990431f05 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragment.kt @@ -98,10 +98,14 @@ class SearchFragment : Fragment(), BackHandler { ) } - searchInteractor = SearchInteractor( + val searchController = DefaultSearchController( activity as HomeActivity, - findNavController(), - searchStore + searchStore, + findNavController() + ) + + searchInteractor = SearchInteractor( + searchController ) awesomeBarView = AwesomeBarView(view.search_layout, searchInteractor) diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt index c88f03413..27c654903 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchInteractor.kt @@ -4,19 +4,8 @@ package org.mozilla.fenix.search -import android.content.Context -import androidx.navigation.NavController import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session -import mozilla.components.support.ktx.kotlin.isUrl -import org.mozilla.fenix.BrowserDirection -import org.mozilla.fenix.HomeActivity -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.metrics -import org.mozilla.fenix.ext.nav -import org.mozilla.fenix.ext.searchEngineManager import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor import org.mozilla.fenix.search.toolbar.ToolbarInteractor @@ -25,104 +14,42 @@ import org.mozilla.fenix.search.toolbar.ToolbarInteractor * Provides implementations for the AwesomeBarView and ToolbarView */ class SearchInteractor( - private val context: Context, - private val navController: NavController, - private val store: SearchStore + private val searchController: SearchController ) : AwesomeBarInteractor, ToolbarInteractor { - data class UserTypingCheck(var ranOnTextChanged: Boolean, var userHasTyped: Boolean) - - private val userTypingCheck = UserTypingCheck(false, !store.state.showShortcutEnginePicker) - 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) - } + searchController.handleUrlCommitted(url) } override fun onEditingCanceled() { - navController.navigateUp() + searchController.handleEditingCancelled() } override fun onTextChanged(text: String) { - store.dispatch(SearchAction.UpdateQuery(text)) - - if (userTypingCheck.ranOnTextChanged && !userTypingCheck.userHasTyped) { - store.dispatch(SearchAction.ShowSearchShortcutEnginePicker(false)) - turnOnStartedTyping() - } - - userTypingCheck.ranOnTextChanged = true + searchController.handleTextChanged(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)) + searchController.handleUrlTapped(url) } 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) + searchController.handleSearchTermsTapped(searchTerms) } override fun onSearchShortcutEngineSelected(searchEngine: SearchEngine) { - store.dispatch(SearchAction.SearchShortcutEngineSelected(searchEngine)) - context.metrics.track(Event.SearchShortcutSelected(searchEngine.name)) + searchController.handleSearchShortcutEngineSelected(searchEngine) } override fun onClickSearchEngineSettings() { - val directions = SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment() - navController.navigate(directions) + searchController.handleClickSearchEngineSettings() } fun turnOnStartedTyping() { - userTypingCheck.ranOnTextChanged = true - userTypingCheck.userHasTyped = true + searchController.handleTurnOnStartedTyping() } override fun onExistingSessionSelected(session: Session) { - val directions = SearchFragmentDirections.actionSearchFragmentToBrowserFragment(null) - navController.nav(R.id.searchFragment, directions) - context.components.core.sessionManager.select(session) - } - - 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) + searchController.handleExistingSessionSelected(session) } } diff --git a/app/src/test/java/org/mozilla/fenix/search/DefaultSearchControllerTest.kt b/app/src/test/java/org/mozilla/fenix/search/DefaultSearchControllerTest.kt new file mode 100644 index 000000000..193e40abd --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/search/DefaultSearchControllerTest.kt @@ -0,0 +1,158 @@ +/* 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.every +import io.mockk.mockk +import io.mockk.verify +import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.session.Session +import mozilla.components.browser.session.SessionManager +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.searchEngineManager + +class DefaultSearchControllerTest { + + private val context: HomeActivity = mockk(relaxed = true) + private val store: SearchStore = mockk(relaxed = true) + private val navController: NavController = mockk(relaxed = true) + private val defaultSearchEngine: SearchEngine? = mockk(relaxed = true) + private val session: Session? = mockk(relaxed = true) + private val searchEngine: SearchEngine = mockk(relaxed = true) + private val metrics: MetricController = mockk(relaxed = true) + private val sessionManager: SessionManager = mockk(relaxed = true) + + private lateinit var controller: DefaultSearchController + + @Before + fun setUp() { + every { store.state.showShortcutEnginePicker } returns false + every { context.searchEngineManager.defaultSearchEngine } returns defaultSearchEngine + every { store.state.session } returns session + every { store.state.searchEngineSource.searchEngine } returns searchEngine + every { context.metrics } returns metrics + every { context.components.core.sessionManager } returns sessionManager + + controller = DefaultSearchController( + context = context, + store = store, + navController = navController + ) + } + + @Test + fun handleUrlCommitted() { + val url = "https://www.google.com/" + + controller.handleUrlCommitted(url) + + verify { context.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = session == null, + from = BrowserDirection.FromSearch, + engine = searchEngine + ) } + verify { metrics.track(Event.EnteredUrl(false)) } + } + + @Test + fun handleEditingCancelled() { + controller.handleEditingCancelled() + + verify { navController.navigateUp() } + } + + @Test + fun handleTextChanged() { + val text = "fenix" + + controller.handleTextChanged(text) + + verify { store.dispatch(SearchAction.UpdateQuery(text)) } + verify(inverse = true) { + store.dispatch(SearchAction.ShowSearchShortcutEnginePicker(false)) + } + assertTrue(controller.userTypingCheck.ranOnTextChanged) + } + + @Test + fun handleUrlTapped() { + val url = "https://www.google.com/" + + controller.handleUrlTapped(url) + + verify { context.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = session == null, + from = BrowserDirection.FromSearch + ) } + verify { metrics.track(Event.EnteredUrl(false)) } + } + + @Test + fun handleSearchTermsTapped() { + val searchTerms = "fenix" + + controller.handleSearchTermsTapped(searchTerms) + + verify { context.openToBrowserAndLoad( + searchTermOrURL = searchTerms, + newTab = session == null, + from = BrowserDirection.FromSearch, + engine = searchEngine, + forceSearch = true + ) } + } + + @Test + fun handleSearchShortcutEngineSelected() { + val searchEngine: SearchEngine = mockk(relaxed = true) + + controller.handleSearchShortcutEngineSelected(searchEngine) + + verify { store.dispatch(SearchAction.SearchShortcutEngineSelected(searchEngine)) } + verify { metrics.track(Event.SearchShortcutSelected(searchEngine.name)) } + } + + @Test + fun handleClickSearchEngineSettings() { + val directions: NavDirections = SearchFragmentDirections.actionSearchFragmentToSearchEngineFragment() + + controller.handleClickSearchEngineSettings() + + verify { navController.navigate(directions) } + } + + @Test + fun handleTurnOnStartedTyping() { + controller.handleTurnOnStartedTyping() + + assertTrue(controller.userTypingCheck.ranOnTextChanged) + assertTrue(controller.userTypingCheck.userHasTyped) + } + + @Test + fun handleExistingSessionSelected() { + val session: Session = mockk(relaxed = true) + val directions = SearchFragmentDirections.actionSearchFragmentToBrowserFragment(null) + + controller.handleExistingSessionSelected(session) + + verify { navController.nav(R.id.searchFragment, directions) } + verify { sessionManager.select(session) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt index 2ef79363c..b2c9dc359 100644 --- a/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/search/SearchInteractorTest.kt @@ -42,7 +42,12 @@ class SearchInteractorTest { every { state.session } returns null every { state.searchEngineSource } returns searchEngine - val interactor = SearchInteractor(context, mockk(), store) + val searchController: SearchController = DefaultSearchController( + context, + store, + mockk() + ) + val interactor = SearchInteractor(searchController) interactor.onUrlCommitted("test") @@ -63,7 +68,12 @@ class SearchInteractorTest { every { store.state } returns mockk(relaxed = true) - val interactor = SearchInteractor(mockk(), navController, store) + val searchController: SearchController = DefaultSearchController( + mockk(), + store, + navController + ) + val interactor = SearchInteractor(searchController) interactor.onEditingCanceled() @@ -78,7 +88,12 @@ class SearchInteractorTest { every { store.state } returns mockk(relaxed = true) - val interactor = SearchInteractor(mockk(), mockk(), store) + val searchController: SearchController = DefaultSearchController( + mockk(), + store, + mockk() + ) + val interactor = SearchInteractor(searchController) interactor.onTextChanged("test") @@ -98,7 +113,12 @@ class SearchInteractorTest { every { state.session } returns null every { state.showShortcutEnginePicker } returns true - val interactor = SearchInteractor(context, mockk(), store) + val searchController: SearchController = DefaultSearchController( + context, + store, + mockk() + ) + val interactor = SearchInteractor(searchController) interactor.onUrlTapped("test") @@ -128,16 +148,24 @@ class SearchInteractorTest { every { state.searchEngineSource } returns searchEngine every { state.showShortcutEnginePicker } returns true - val interactor = SearchInteractor(context, mockk(), store) + val searchController: SearchController = DefaultSearchController( + context, + store, + mockk() + ) + + val interactor = SearchInteractor(searchController) interactor.onSearchTermsTapped("test") - verify { context.openToBrowserAndLoad( - searchTermOrURL = "test", - newTab = true, - from = BrowserDirection.FromSearch, - engine = searchEngine.searchEngine, - forceSearch = true - ) } + verify { + context.openToBrowserAndLoad( + searchTermOrURL = "test", + newTab = true, + from = BrowserDirection.FromSearch, + engine = searchEngine.searchEngine, + forceSearch = true + ) + } } @Test @@ -151,7 +179,12 @@ class SearchInteractorTest { every { store.state } returns state - val interactor = SearchInteractor(context, mockk(), store) + val searchController: SearchController = DefaultSearchController( + context, + store, + mockk() + ) + val interactor = SearchInteractor(searchController) val searchEngine: SearchEngine = mockk(relaxed = true) interactor.onSearchShortcutEngineSelected(searchEngine) @@ -166,7 +199,12 @@ class SearchInteractorTest { every { store.state } returns mockk(relaxed = true) - val interactor = SearchInteractor(mockk(), navController, store) + val searchController: SearchController = DefaultSearchController( + mockk(), + store, + navController + ) + val interactor = SearchInteractor(searchController) every { navController.navigate(any() as NavDirections) } just Runs @@ -180,19 +218,31 @@ class SearchInteractorTest { @Test fun onExistingSessionSelected() { val navController: NavController = mockk(relaxed = true) - every { navController.currentDestination } returns NavDestination("").apply { id = R.id.searchFragment } + every { navController.currentDestination } returns NavDestination("").apply { + id = R.id.searchFragment + } val context: Context = mockk(relaxed = true) val applicationContext: FenixApplication = mockk(relaxed = true) every { context.applicationContext } returns applicationContext val store: SearchStore = mockk() every { store.state } returns mockk(relaxed = true) - val interactor = SearchInteractor(context, navController, store) + + val searchController: SearchController = DefaultSearchController( + context, + store, + navController + ) + val interactor = SearchInteractor(searchController) val session = Session("http://mozilla.org", false) interactor.onExistingSessionSelected(session) verify { - navController.navigate(SearchFragmentDirections.actionSearchFragmentToBrowserFragment(null)) + navController.navigate( + SearchFragmentDirections.actionSearchFragmentToBrowserFragment( + null + ) + ) } } }