From f3df2c73d90475528504711100e0ca5a92c2f4aa Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Mon, 12 Apr 2021 22:57:01 +0400 Subject: [PATCH] Close #18862: Add multi-select banner to tabs tray (#18932) * Issue #18862: Add new addBookmark BookmarksUseCase * Issue #18862: Add class for state binding features * Issue #18862: Add delete multiple tabs to tray interactor * Issue #18862: Add new actions to navigation interactor * Issue #18862: Enable select mode from main tray menu * Issue #18862: Add menu when in select mode * Close #18862: Add multi-select banner to tabs tray * Close #18862: Add select support for handle UI We apply various layout changes to the "handle" UI in the tabs tray when switching modes. It isn't quite clear to my, why we do this, if it's really needed to meet the end result, and if there is a better way. For now, we're simplying moving over that logic that we can re-evaluate at a later time. --- .../fenix/components/AbstractBinding.kt | 43 ++++++ .../mozilla/fenix/components/Components.kt | 3 +- .../org/mozilla/fenix/components/UseCases.kt | 10 +- .../components/bookmarks/BookmarksUseCase.kt | 42 ++++++ .../mozilla/fenix/tabstray/MenuIntegration.kt | 44 ++---- .../fenix/tabstray/NavigationInteractor.kt | 63 +++++++- .../fenix/tabstray/TabsTrayFragment.kt | 63 +++++++- .../fenix/tabstray/TabsTrayInteractor.kt | 9 +- .../tabstray/browser/BaseBrowserTrayList.kt | 2 +- .../tabstray/browser/BrowserTrayInteractor.kt | 2 +- .../browser/SelectionBannerBinding.kt | 141 ++++++++++++++++++ .../browser/SelectionHandleBinding.kt | 108 ++++++++++++++ .../fenix/tabstray/browser/SelectionMenu.kt | 40 +++++ .../browser/SelectionMenuIntegration.kt | 41 +++++ .../mozilla/fenix/tabstray/ext/BrowserMenu.kt | 25 ++++ .../fenix/components/AbstractBindingTest.kt | 71 +++++++++ .../fenix/components/TestComponents.kt | 3 +- .../org/mozilla/fenix/components/TestCore.kt | 2 + .../bookmarks/BookmarksUseCaseTest.kt | 50 +++++++ .../fenix/tabstray/MenuIntegrationTest.kt | 15 +- .../tabstray/NavigationInteractorTest.kt | 66 +++++++- .../browser/SelectionMenuIntegrationTest.kt | 37 +++++ 22 files changed, 824 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt create mode 100644 app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt create mode 100644 app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt b/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt new file mode 100644 index 0000000000..cdc9fc7554 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt @@ -0,0 +1,43 @@ +/* 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.annotation.CallSuper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature + +/** + * Helper class for creating small binding classes that are responsible for reacting to state + * changes. + * + * Taken with ♥️ from Focus. + */ +abstract class AbstractBinding( + private val store: Store +) : LifecycleAwareFeature { + private var scope: CoroutineScope? = null + + @OptIn(ExperimentalCoroutinesApi::class) + @CallSuper + override fun start() { + scope = store.flowScoped { flow -> + onState(flow) + } + } + + @CallSuper + override fun stop() { + scope?.cancel() + } + + abstract suspend fun onState(flow: Flow) +} diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 9990d32fb8..1a0a48f360 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -70,7 +70,8 @@ class Components(private val context: Context) { core.sessionManager, core.store, core.webAppShortcutManager, - core.topSitesStorage + core.topSitesStorage, + core.bookmarksStorage ) } diff --git a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt index 9249c8ab8f..bd9a4fb211 100644 --- a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt +++ b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt @@ -8,6 +8,7 @@ import android.content.Context import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine +import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.feature.app.links.AppLinksUseCases import mozilla.components.feature.contextmenu.ContextMenuUseCases import mozilla.components.feature.downloads.DownloadsUseCases @@ -23,6 +24,7 @@ import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.top.sites.TopSitesStorage import mozilla.components.feature.top.sites.TopSitesUseCases import mozilla.components.support.locale.LocaleUseCases +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.utils.Mockable @@ -38,7 +40,8 @@ class UseCases( private val sessionManager: SessionManager, private val store: BrowserStore, private val shortcutManager: WebAppShortcutManager, - private val topSitesStorage: TopSitesStorage + private val topSitesStorage: TopSitesStorage, + private val bookmarksStorage: BookmarksStorage ) { /** * Use cases that provide engine interactions for a given browser session. @@ -94,4 +97,9 @@ class UseCases( * Use cases that handle locale management. */ val localeUseCases by lazyMonitored { LocaleUseCases(store) } + + /** + * Use cases that provide bookmark management. + */ + val bookmarksUseCases by lazyMonitored { BookmarksUseCase(bookmarksStorage) } } diff --git a/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt b/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt new file mode 100644 index 0000000000..e381312fa2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt @@ -0,0 +1,42 @@ +/* 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.bookmarks + +import androidx.annotation.WorkerThread +import mozilla.appservices.places.BookmarkRoot +import mozilla.components.concept.storage.BookmarksStorage + +/** + * Use cases that allow for modifying bookmarks. + */ +class BookmarksUseCase(storage: BookmarksStorage) { + + class AddBookmarksUseCase internal constructor(private val storage: BookmarksStorage) { + + /** + * Adds a new bookmark with the provided [url] and [title]. + * + * @return The result if the operation was executed or not. A bookmark may not be added if + * one with the identical [url] already exists. + */ + @WorkerThread + suspend operator fun invoke(url: String, title: String, position: Int? = null): Boolean { + val canAdd = storage.getBookmarksWithUrl(url).firstOrNull { it.url == it.url } == null + + if (canAdd) { + storage.addItem( + BookmarkRoot.Mobile.id, + url = url, + title = title, + position = position + ) + } + + return canAdd + } + } + + val addBookmark by lazy { AddBookmarksUseCase(storage) } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt b/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt index 42d55680b4..db8139f5da 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt @@ -5,15 +5,11 @@ package org.mozilla.fenix.tabstray import android.content.Context -import android.view.View import androidx.annotation.VisibleForTesting -import androidx.cardview.widget.CardView -import androidx.core.content.ContextCompat import com.google.android.material.tabs.TabLayout -import mozilla.components.browser.menu.BrowserMenu import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.state.store.BrowserStore -import org.mozilla.fenix.R +import org.mozilla.fenix.utils.Do /** * A wrapper class that building the tabs tray menu that handles item clicks. @@ -43,31 +39,19 @@ class MenuIntegration( fun build() = tabsTrayItemMenu.menuBuilder.build(context) @VisibleForTesting - internal fun handleMenuClicked(item: TabsTrayMenu.Item) = when (item) { - is TabsTrayMenu.Item.ShareAllTabs -> - navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode) - is TabsTrayMenu.Item.OpenTabSettings -> - navigationInteractor.onTabSettingsClicked() - is TabsTrayMenu.Item.CloseAllTabs -> - navigationInteractor.onCloseAllTabsClicked(isPrivateMode) - is TabsTrayMenu.Item.OpenRecentlyClosed -> - navigationInteractor.onOpenRecentlyClosedClicked() - is TabsTrayMenu.Item.SelectTabs -> { - /* TODO implement when mulitiselect call is available */ + internal fun handleMenuClicked(item: TabsTrayMenu.Item) { + Do exhaustive when (item) { + is TabsTrayMenu.Item.ShareAllTabs -> + navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode) + is TabsTrayMenu.Item.OpenTabSettings -> + navigationInteractor.onTabSettingsClicked() + is TabsTrayMenu.Item.CloseAllTabs -> + navigationInteractor.onCloseAllTabsClicked(isPrivateMode) + is TabsTrayMenu.Item.OpenRecentlyClosed -> + navigationInteractor.onOpenRecentlyClosedClicked() + is TabsTrayMenu.Item.SelectTabs -> { + tabsTrayStore.dispatch(TabsTrayAction.EnterSelectMode) + } } } } - -/** - * Invokes [BrowserMenu.show] and applies the default theme color background. - */ -fun BrowserMenu.showWithTheme(view: View) { - show(view).also { popupMenu -> - (popupMenu.contentView as? CardView)?.setCardBackgroundColor( - ContextCompat.getColor( - view.context, - R.color.foundation_normal_theme - ) - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt index 80efccbcb6..543c868e9d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt @@ -5,10 +5,15 @@ package org.mozilla.fenix.tabstray import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.tabstray.Tab +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.home.HomeFragment @@ -24,35 +29,53 @@ interface NavigationInteractor { fun onTabTrayDismissed() /** - * Called when user clicks the share tabs button. + * Called when sharing a list of [Tab]s. + */ + fun onShareTabs(tabs: Collection) + + /** + * Called when clicking the share tabs button. */ fun onShareTabsOfTypeClicked(private: Boolean) /** - * Called when user clicks the tab settings button. + * Called when clicking the tab settings button. */ fun onTabSettingsClicked() /** - * Called when user clicks the close all tabs button. + * Called when clicking the close all tabs button. */ fun onCloseAllTabsClicked(private: Boolean) /** - * Called when user clicks the recently closed tabs menu button. + * Called when opening the recently closed tabs menu button. */ fun onOpenRecentlyClosedClicked() + + /** + * Used when opening the add-to-collections user flow. + */ + fun onSaveToCollections(tabs: Collection) + + /** + * Used when adding [Tab]s as bookmarks. + */ + fun onSaveToBookmarks(tabs: Collection) } /** * A default implementation of [NavigationInteractor]. */ +@Suppress("LongParameterList") class DefaultNavigationInteractor( + private val tabsTrayStore: TabsTrayStore, private val browserStore: BrowserStore, private val navController: NavController, private val metrics: MetricController, private val dismissTabTray: () -> Unit, - private val dismissTabTrayAndNavigateHome: (String) -> Unit + private val dismissTabTrayAndNavigateHome: (String) -> Unit, + private val bookmarksUseCase: BookmarksUseCase ) : NavigationInteractor { override fun onTabTrayDismissed() { @@ -68,6 +91,16 @@ class DefaultNavigationInteractor( metrics.track(Event.RecentlyClosedTabsOpened) } + override fun onShareTabs(tabs: Collection) { + val data = tabs.map { + ShareData(url = it.url, title = it.title) + } + val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( + data = data.toTypedArray() + ) + navController.navigate(directions) + } + override fun onShareTabsOfTypeClicked(private: Boolean) { val tabs = browserStore.state.getNormalOrPrivateTabs(private) val data = tabs.map { @@ -89,4 +122,24 @@ class DefaultNavigationInteractor( dismissTabTrayAndNavigateHome(sessionsToClose) } + + override fun onSaveToCollections(tabs: Collection) { + metrics.track(Event.TabsTraySaveToCollectionPressed) + + // TODO add this is a separate PR; it's quite a large change. + } + + override fun onSaveToBookmarks(tabs: Collection) { + tabs.forEach { tab -> + // We don't combine the context with lifecycleScope so that our jobs are not cancelled + // if we leave the fragment, i.e. we still want the bookmarks to be added. + CoroutineScope(Dispatchers.IO).launch { + bookmarksUseCase.addBookmark(tab.url, tab.title) + } + } + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + + // TODO show successful snackbar here (regardless of operation succes). + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index 9e20a0b5a6..cced95358d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -15,13 +15,18 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.android.synthetic.main.component_tabstray.view.* import kotlinx.android.synthetic.main.component_tabstray2.* import kotlinx.android.synthetic.main.component_tabstray2.view.* +import kotlinx.android.synthetic.main.component_tabstray2.view.tab_tray_overflow +import kotlinx.android.synthetic.main.component_tabstray2.view.tab_wrapper import kotlinx.android.synthetic.main.component_tabstray_fab.* import kotlinx.android.synthetic.main.tabs_tray_tab_counter2.* +import kotlinx.android.synthetic.main.tabstray_multiselect_items.* +import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.plus +import mozilla.components.concept.tabstray.Tab import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections @@ -32,8 +37,13 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.DefaultBrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.SelectionHandleBinding +import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding +import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding.VisibilityModifier +import org.mozilla.fenix.tabstray.ext.showWithTheme import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor +@Suppress("TooManyFunctions") class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private var fabView: View? = null @@ -45,6 +55,8 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private val tabLayoutMediator = ViewBoundFeatureWrapper() private val tabCounterBinding = ViewBoundFeatureWrapper() private val floatingActionButtonBinding = ViewBoundFeatureWrapper() + private val selectionBannerBinding = ViewBoundFeatureWrapper() + private val selectionHandleBinding = ViewBoundFeatureWrapper() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,7 +85,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { return containerView } - @ExperimentalCoroutinesApi + @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val activity = activity as HomeActivity @@ -100,11 +112,13 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val navigationInteractor = DefaultNavigationInteractor( + tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, navController = findNavController(), metrics = requireComponents.analytics.metrics, dismissTabTray = ::dismissAllowingStateLoss, - dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome + dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, + bookmarksUseCase = requireComponents.useCases.bookmarksUseCases ) val syncedTabsTrayInteractor = SyncedTabsInteractor( @@ -152,6 +166,41 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { owner = this, view = view ) + + selectionBannerBinding.set( + feature = SelectionBannerBinding( + context = requireContext(), + store = tabsTrayStore, + navInteractor = navigationInteractor, + tabsTrayInteractor = this, + containerView = view, + backgroundView = topBar, + showOnSelectViews = VisibilityModifier( + collect_multi_select, + share_multi_select, + menu_multi_select, + multiselect_title, + exit_multi_select + ), + showOnNormalViews = VisibilityModifier( + tab_layout, + tab_tray_overflow, + new_tab_button + ) + ), + owner = this, + view = view + ) + + selectionHandleBinding.set( + feature = SelectionHandleBinding( + store = tabsTrayStore, + handle = handle, + containerLayout = tab_wrapper + ), + owner = this, + view = view + ) } override fun setCurrentTrayPosition(position: Int, smoothScroll: Boolean) { @@ -172,7 +221,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { } } - override fun tabRemoved(tabId: String) { + override fun onDeleteTab(tabId: String) { // TODO re-implement these methods // showUndoSnackbarForTab(sessionId) // removeIfNotLastTab(sessionId) @@ -181,6 +230,12 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { requireComponents.useCases.tabsUseCases.removeTab(tabId) } + override fun onDeleteTabs(tabs: Collection) { + tabs.forEach { + onDeleteTab(it.id) + } + } + private fun setupPager( context: Context, store: TabsTrayStore, diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt index 9fd950a6b9..5c3a7371a5 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt @@ -4,6 +4,8 @@ package org.mozilla.fenix.tabstray +import mozilla.components.concept.tabstray.Tab + interface TabsTrayInteractor { /** * Set the current tray item to the clamped [position]. @@ -21,5 +23,10 @@ interface TabsTrayInteractor { /** * Invoked when a tab is removed from the tabs tray with the given [tabId]. */ - fun tabRemoved(tabId: String) + fun onDeleteTab(tabId: String) + + /** + * Invoked when [Tab]s need to be deleted. + */ + fun onDeleteTabs(tabs: Collection) } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt index 03f70a11ef..1bfcee8615 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt @@ -48,7 +48,7 @@ abstract class BaseBrowserTrayList @JvmOverloads constructor( val removeTabUseCase = RemoveTabUseCaseWrapper( context.components.analytics.metrics ) { sessionId -> - interactor.tabRemoved(sessionId) + interactor.onDeleteTab(sessionId) } TabsFeature( diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt index 5a85b77b57..49cb4784d1 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt @@ -64,7 +64,7 @@ class DefaultBrowserTrayInteractor( private val removeTabWrapper by lazy { RemoveTabUseCaseWrapper(metrics) { // Handle removal from the interactor where we can also handle "undo" visuals. - trayInteractor.tabRemoved(it) + trayInteractor.onDeleteTab(it) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt new file mode 100644 index 0000000000..8850049a4b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.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.tabstray.browser + +import android.content.Context +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.component_tabstray2.view.exit_multi_select +import kotlinx.android.synthetic.main.component_tabstray2.view.multiselect_title +import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.components.AbstractBinding +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.TabsTrayAction.ExitSelectMode +import org.mozilla.fenix.tabstray.TabsTrayState.Mode +import org.mozilla.fenix.tabstray.TabsTrayState.Mode.Select +import org.mozilla.fenix.tabstray.ext.showWithTheme + +/** + * A binding that shows/hides the multi-select banner of the selected count of tabs. + * + * @property context An Android context. + * @property store The TabsTrayStore instance. + * @property navInteractor An instance of [NavigationInteractor] for navigating on menu clicks. + * @property tabsTrayInteractor An instance of [TabsTrayInteractor] for handling deletion. + * @property containerView The view in the layout that contains all the implicit multi-select + * views. NB: This parameter is a bit opaque and requires a larger layout refactor to correct. + * @property backgroundView The background view that we want to alter when changing [Mode]. + * @property showOnSelectViews A variable list of views that will be made visible when in select mode. + * @property showOnNormalViews A variable list of views that will be made visible when in normal mode. + */ +@Suppress("LongParameterList") +class SelectionBannerBinding( + private val context: Context, + private val store: TabsTrayStore, + private val navInteractor: NavigationInteractor, + private val tabsTrayInteractor: TabsTrayInteractor, + private val containerView: View, + private val backgroundView: View, + private val showOnSelectViews: VisibilityModifier, + private val showOnNormalViews: VisibilityModifier +) : AbstractBinding(store) { + + /** + * A holder of views that will be used by having their [View.setVisibility] modified. + */ + class VisibilityModifier(vararg val views: View) + + private var isPreviousModeSelect = false + + override fun start() { + super.start() + + initListeners(containerView) + } + + override suspend fun onState(flow: Flow) { + flow.map { it.mode } + // ignore initial mode update; we never start in select mode. + .drop(1) + .ifChanged() + .collect { mode -> + val isSelectMode = mode is Select + + showOnSelectViews.views.forEach { + it.isVisible = isSelectMode + } + + showOnNormalViews.views.forEach { + it.isVisible = isSelectMode.not() + } + + updateBackgroundColor(isSelectMode) + + updateSelectTitle(isSelectMode, mode.selectedTabs.size) + + isPreviousModeSelect = isSelectMode + } + } + + private fun initListeners(containerView: View) { + containerView.share_multi_select.setOnClickListener { + navInteractor.onShareTabs(store.state.mode.selectedTabs) + } + + containerView.collect_multi_select.setOnClickListener { + navInteractor.onSaveToCollections(store.state.mode.selectedTabs) + } + + containerView.exit_multi_select.setOnClickListener { + store.dispatch(ExitSelectMode) + } + + containerView.menu_multi_select.setOnClickListener { anchor -> + val menu = SelectionMenuIntegration( + context, + store, + navInteractor, + tabsTrayInteractor + ).build() + + menu.showWithTheme(anchor) + } + } + + @VisibleForTesting + private fun updateBackgroundColor(isSelectMode: Boolean) { + // memoize to avoid setting the background unnecessarily. + if (isPreviousModeSelect != isSelectMode) { + val colorResource = if (isSelectMode) { + R.color.accent_normal_theme + } else { + R.color.foundation_normal_theme + } + + val color = ContextCompat.getColor(backgroundView.context, colorResource) + + backgroundView.setBackgroundColor(color) + } + } + + @VisibleForTesting + private fun updateSelectTitle(selectedMode: Boolean, tabCount: Int) { + if (selectedMode) { + containerView.multiselect_title.text = + context.getString(R.string.tab_tray_multi_select_title, tabCount) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt new file mode 100644 index 0000000000..6560b8d934 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt @@ -0,0 +1,108 @@ +/* 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.tabstray.browser + +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.components.AbstractBinding +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.TabsTrayState.Mode +import org.mozilla.fenix.tabstray.TabsTrayStore + +private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F + +/** + * Various layout updates that need to be applied to the "handle" view when switching + * between [Mode]. + * + * @param store The TabsTrayStore instance. + * @property handle The "handle" of the Tabs Tray that is used to drag the tray open/close. + * @property containerLayout The [ConstraintLayout] that contains the "handle". + */ +class SelectionHandleBinding( + store: TabsTrayStore, + private val handle: View, + private val containerLayout: ConstraintLayout +) : AbstractBinding(store) { + + private var isPreviousModeSelect = false + + override suspend fun onState(flow: Flow) { + flow.map { it.mode } + // ignore initial mode update; we never start in select mode. + .drop(1) + .ifChanged() + .collect { mode -> + val isSelectMode = mode is Mode.Select + + // memoize to avoid unnecessary layout updates. + if (isPreviousModeSelect != isSelectMode) { + updateLayoutParams(handle, isSelectMode) + + updateBackgroundColor(handle, isSelectMode) + + updateWidthPercent(containerLayout, handle, isSelectMode) + } + + isPreviousModeSelect = isSelectMode + } + } + + private fun updateLayoutParams(handle: View, multiselect: Boolean) { + handle.updateLayoutParams { + height = handle.resources.getDimensionPixelSize( + if (multiselect) { + R.dimen.tab_tray_multiselect_handle_height + } else { + R.dimen.bottom_sheet_handle_height + } + ) + topMargin = handle.resources.getDimensionPixelSize( + if (multiselect) { + R.dimen.tab_tray_multiselect_handle_top_margin + } else { + R.dimen.bottom_sheet_handle_top_margin + } + ) + } + } + + private fun updateBackgroundColor(handle: View, multiselect: Boolean) { + val colorResource = if (multiselect) { + R.color.accent_normal_theme + } else { + R.color.secondary_text_normal_theme + } + + val color = ContextCompat.getColor(handle.context, colorResource) + + handle.setBackgroundColor(color) + } + + private fun updateWidthPercent( + container: ConstraintLayout, + handle: View, + multiselect: Boolean + ) { + val widthPercent = if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH + container.run { + ConstraintSet().apply { + clone(this@run) + constrainPercentWidth(handle.id, widthPercent) + applyTo(this@run) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt new file mode 100644 index 0000000000..1a789acb67 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt @@ -0,0 +1,40 @@ +/* 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.tabstray.browser + +import android.content.Context +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import org.mozilla.fenix.R + +class SelectionMenu( + private val context: Context, + private val onItemTapped: (Item) -> Unit = {} +) { + sealed class Item { + object BookmarkTabs : Item() + object DeleteTabs : Item() + } + + val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } + + private val menuItems by lazy { + listOf( + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_multiselect_menu_item_bookmark), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.BookmarkTabs) + }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_multiselect_menu_item_close), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.DeleteTabs) + } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt new file mode 100644 index 0000000000..a865ebae77 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt @@ -0,0 +1,41 @@ +/* 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.tabstray.browser + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.menu.BrowserMenuBuilder +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.utils.Do + +class SelectionMenuIntegration( + private val context: Context, + private val store: TabsTrayStore, + private val navInteractor: NavigationInteractor, + private val trayInteractor: TabsTrayInteractor +) { + private val menu by lazy { + SelectionMenu(context, ::handleMenuClicked) + } + + /** + * Builds the internal menu items list. See [BrowserMenuBuilder.build]. + */ + fun build() = menu.menuBuilder.build(context) + + @VisibleForTesting + internal fun handleMenuClicked(item: SelectionMenu.Item) { + Do exhaustive when (item) { + is SelectionMenu.Item.BookmarkTabs -> navInteractor.onSaveToBookmarks( + store.state.mode.selectedTabs + ) + is SelectionMenu.Item.DeleteTabs -> trayInteractor.onDeleteTabs( + store.state.mode.selectedTabs + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt new file mode 100644 index 0000000000..ec98a59ec2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt @@ -0,0 +1,25 @@ +/* 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.tabstray.ext + +import android.view.View +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat +import mozilla.components.browser.menu.BrowserMenu +import org.mozilla.fenix.R + +/** + * Invokes [BrowserMenu.show] and applies the default theme color background. + */ +fun BrowserMenu.showWithTheme(view: View) { + show(view).also { popupMenu -> + (popupMenu.contentView as? CardView)?.setCardBackgroundColor( + ContextCompat.getColor( + view.context, + R.color.foundation_normal_theme + ) + ) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt b/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt new file mode 100644 index 0000000000..55177b86ab --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt @@ -0,0 +1,71 @@ +/* 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AbstractBindingTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + + @Test + fun `WHEN started THEN onState flow is invoked`() { + val store = BrowserStore() + var invoked = false + val binding = TestBinding(store) { + invoked = true + } + + binding.start() + + store.waitUntilIdle() + + assertTrue(invoked) + } + + @Test + fun `WHEN actions are dispatched THEN onState flow is invoked`() { + val store = BrowserStore() + var invoked = false + val binding = TestBinding(store) { + if (store.state.tabs.isNotEmpty()) { + invoked = true + } + } + + binding.start() + + store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org"))) + + store.waitUntilIdle() + + assertTrue(invoked) + } + + class TestBinding( + store: BrowserStore, + private val invoked: (BrowserState) -> Unit + ) : AbstractBinding(store) { + override suspend fun onState(flow: Flow) { + flow.collect { + invoked(it) + } + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt b/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt index dfc88b0e96..3aa8d41dcc 100644 --- a/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt +++ b/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt @@ -24,7 +24,8 @@ class TestComponents(private val context: Context) : Components(context) { core.sessionManager, core.store, core.webAppShortcutManager, - core.topSitesStorage + core.topSitesStorage, + core.bookmarksStorage ) } override val intentProcessors by lazy { mockk(relaxed = true) } diff --git a/app/src/test/java/org/mozilla/fenix/components/TestCore.kt b/app/src/test/java/org/mozilla/fenix/components/TestCore.kt index 70dccaebd4..d5161017e6 100644 --- a/app/src/test/java/org/mozilla/fenix/components/TestCore.kt +++ b/app/src/test/java/org/mozilla/fenix/components/TestCore.kt @@ -9,6 +9,7 @@ import io.mockk.every import io.mockk.mockk import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.PlacesBookmarksStorage import mozilla.components.browser.thumbnails.storage.ThumbnailStorage import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.concept.engine.Engine @@ -32,4 +33,5 @@ class TestCore(context: Context, crashReporter: CrashReporting) : Core( override val webAppShortcutManager = mockk() override val thumbnailStorage = mockk() override val topSitesStorage = mockk() + override val bookmarksStorage = mockk() } diff --git a/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt b/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt new file mode 100644 index 0000000000..57619a395d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt @@ -0,0 +1,50 @@ +/* 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.bookmarks + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import mozilla.appservices.places.BookmarkRoot +import mozilla.components.concept.storage.BookmarkNode +import mozilla.components.concept.storage.BookmarksStorage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@ExperimentalCoroutinesApi +class BookmarksUseCaseTest { + + @Test + fun `WHEN adding existing bookmark THEN no new item is stored`() = runBlockingTest { + val storage = mockk() + val bookmarkNode = mockk() + val useCase = BookmarksUseCase(storage) + + every { bookmarkNode.url }.answers { "https://mozilla.org" } + coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) } + + val result = useCase.addBookmark("https://mozilla.org", "Mozilla") + + assertFalse(result) + } + + @Test + fun `WHEN adding bookmark THEN new item is stored`() = runBlockingTest { + val storage = mockk(relaxed = true) + val useCase = BookmarksUseCase(storage) + + coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { emptyList() } + + val result = useCase.addBookmark("https://mozilla.org", "Mozilla") + + assertTrue(result) + + coVerify { storage.addItem(BookmarkRoot.Mobile.id, "https://mozilla.org", "Mozilla", null) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt index b789d6e0c7..bbf795d8f1 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt @@ -6,13 +6,21 @@ package org.mozilla.fenix.tabstray import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertNotNull -import org.junit.Ignore +import org.junit.Rule import org.junit.Test class MenuIntegrationTest { + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + private val captureMiddleware = CaptureActionsMiddleware() private val tabsTrayStore = TabsTrayStore(middlewares = listOf(captureMiddleware)) private val interactor = mockk(relaxed = true) @@ -53,12 +61,13 @@ class MenuIntegrationTest { verify { interactor.onOpenRecentlyClosedClicked() } } - @Ignore("Enable after we connect this menu item to the store") @Test fun `WHEN the select menu item is clicked THEN invoke the action`() { val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor) - menu.handleMenuClicked(TabsTrayMenu.Item.ShareAllTabs) + menu.handleMenuClicked(TabsTrayMenu.Item.SelectTabs) + + tabsTrayStore.waitUntilIdle() assertNotNull(captureMiddleware.findLastAction(TabsTrayAction.EnterSelectMode::class)) } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt index 4201afc80d..3ddf4f2d4f 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt @@ -6,34 +6,45 @@ package org.mozilla.fenix.tabstray import androidx.navigation.NavController import androidx.navigation.NavDirections +import io.mockk.coVerify import io.mockk.mockk import io.mockk.verify import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState -import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.state.createTab as createStateTab import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase +import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.tabstray.browser.createTab as createTrayTab class NavigationInteractorTest { private lateinit var store: BrowserStore + private lateinit var tabsTrayStore: TabsTrayStore private lateinit var navigationInteractor: NavigationInteractor - private val testTab: TabSessionState = createTab(url = "https://mozilla.org") + private val testTab: TabSessionState = createStateTab(url = "https://mozilla.org") private val navController: NavController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true) private val dismissTabTray: () -> Unit = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: (String) -> Unit = mockk(relaxed = true) + private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true) @Before fun setup() { store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab))) + tabsTrayStore = TabsTrayStore() navigationInteractor = DefaultNavigationInteractor( + tabsTrayStore, store, navController, metrics, dismissTabTray, - dismissTabTrayAndNavigateHome + dismissTabTrayAndNavigateHome, + bookmarksUseCase ) } @@ -44,6 +55,9 @@ class NavigationInteractorTest { var openRecentlyClosedClicked = false var shareTabsOfTypeClicked = false var closeAllTabsClicked = false + var onShareTabs = false + var onSaveToCollections = false + var onBookmarkTabs = false class TestNavigationInteractor : NavigationInteractor { @@ -51,6 +65,10 @@ class NavigationInteractorTest { tabTrayDismissed = true } + override fun onShareTabs(tabs: Collection) { + onShareTabs = true + } + override fun onTabSettingsClicked() { tabSettingsClicked = true } @@ -59,6 +77,14 @@ class NavigationInteractorTest { openRecentlyClosedClicked = true } + override fun onSaveToCollections(tabs: Collection) { + onSaveToCollections = true + } + + override fun onSaveToBookmarks(tabs: Collection) { + onBookmarkTabs = true + } + override fun onShareTabsOfTypeClicked(private: Boolean) { shareTabsOfTypeClicked = true } @@ -70,15 +96,21 @@ class NavigationInteractorTest { val navigationInteractor: NavigationInteractor = TestNavigationInteractor() navigationInteractor.onTabTrayDismissed() - assert(tabTrayDismissed) + assertTrue(tabTrayDismissed) navigationInteractor.onTabSettingsClicked() - assert(tabSettingsClicked) + assertTrue(tabSettingsClicked) navigationInteractor.onOpenRecentlyClosedClicked() - assert(openRecentlyClosedClicked) + assertTrue(openRecentlyClosedClicked) navigationInteractor.onShareTabsOfTypeClicked(true) - assert(shareTabsOfTypeClicked) + assertTrue(shareTabsOfTypeClicked) navigationInteractor.onCloseAllTabsClicked(true) - assert(closeAllTabsClicked) + assertTrue(closeAllTabsClicked) + navigationInteractor.onShareTabs(emptyList()) + assertTrue(onShareTabs) + navigationInteractor.onSaveToCollections(emptyList()) + assertTrue(onSaveToCollections) + navigationInteractor.onSaveToBookmarks(emptyList()) + assertTrue(onBookmarkTabs) } @Test @@ -110,4 +142,22 @@ class NavigationInteractorTest { navigationInteractor.onShareTabsOfTypeClicked(false) verify(exactly = 1) { navController.navigate(any()) } } + + @Test + fun `onShareTabs calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onShareTabs(emptyList()) + verify(exactly = 1) { navController.navigate(any()) } + } + + @Test + fun `onSaveToCollections calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onSaveToCollections(emptyList()) + verify(exactly = 1) { metrics.track(Event.TabsTraySaveToCollectionPressed) } + } + + @Test + fun `onBookmarkTabs calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onSaveToBookmarks(listOf(createTrayTab())) + coVerify(exactly = 1) { bookmarksUseCase.addBookmark(any(), any(), any()) } + } } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt new file mode 100644 index 0000000000..24a2635dfe --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt @@ -0,0 +1,37 @@ +/* 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.tabstray.browser + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayStore + +class SelectionMenuIntegrationTest { + + private val navInteractor = mockk(relaxed = true) + private val trayInteractor = mockk(relaxed = true) + private val store = TabsTrayStore() + + @Test + fun `WHEN bookmark item is clicked THEN invoke interactor`() { + val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor) + + menu.handleMenuClicked(SelectionMenu.Item.BookmarkTabs) + + verify { navInteractor.onSaveToBookmarks(store.state.mode.selectedTabs) } + } + + @Test + fun `WHEN delete tabs item is clicked THEN invoke interactor`() { + val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor) + + menu.handleMenuClicked(SelectionMenu.Item.DeleteTabs) + + verify { trayInteractor.onDeleteTabs(store.state.mode.selectedTabs) } + } +}