From d342aeae48f3f265dea22aa53956db907a07a293 Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Tue, 13 Apr 2021 15:13:12 -0400 Subject: [PATCH] Close #18931: Implement add to collections in interactor We moved the collection dialog code out from the old fragment, because it had nothing to do with tabs tray, and into the collections package to be re-usable in other parts of the app. In addition, we also make use of it in the new tabs tray's NavigationInteractor. --- .../fenix/collections/CollectionsDialog.kt | 135 ++++++++++++++++++ .../fenix/tabstray/NavigationInteractor.kt | 32 ++++- .../fenix/tabstray/TabsTrayFragment.kt | 4 +- .../fenix/tabstray/ext/BrowserStore.kt | 19 +++ .../tabstray/NavigationInteractorTest.kt | 20 ++- .../fenix/tabstray/ext/BrowserStoreKtTest.kt | 60 ++++++++ 6 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt new file mode 100644 index 0000000000..a5c6a8ba83 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt @@ -0,0 +1,135 @@ +/* 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.collections + +import android.content.Context +import android.view.LayoutInflater +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.feature.tab.collections.TabCollection +import mozilla.components.support.ktx.android.view.showKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.components.TabCollectionStorage +import org.mozilla.fenix.ext.getDefaultCollectionNumber + +/** + * A lambda that is invoked when a confirmation button in a [CollectionsDialog] is clicked. + * + * A [TabCollection] of the selected collected is passed to the delegate when confirmed. If null, + * then a new collection is created. + * + * A list of [TabSessionState] is returned that will be put into the collections storage. + */ +typealias OnPositiveButtonClick = (collection: TabCollection?) -> List + +/** + * A lambda that is invoked when a cancel button in a [CollectionsDialog] is clicked. + */ +typealias OnNegativeButtonClick = () -> Unit + +/** + * A data class for creating a dialog to prompt adding/creating a collection. See also [show]. + * + * @property onPositiveButtonClick Invoked when a user clicks on a confirmation button in the dialog. + * @property onNegativeButtonClick Invoked when a user clicks on a cancel button in the dialog. + */ +data class CollectionsDialog( + val storage: TabCollectionStorage, + val onPositiveButtonClick: OnPositiveButtonClick, + val onNegativeButtonClick: OnNegativeButtonClick +) + +/** + * Create and display a [CollectionsDialog] using [AlertDialog]. + */ +fun CollectionsDialog.show( + context: Context +) { + if (storage.cachedTabCollections.isEmpty()) { + showAddNewDialog(context, storage) + return + } + + val collections = storage.cachedTabCollections.map { it.title } + val layout = LayoutInflater.from(context).inflate(R.layout.add_new_collection_dialog, null) + val list = layout.findViewById(R.id.recycler_view) + + val builder = AlertDialog.Builder(context).setTitle(R.string.tab_tray_select_collection) + .setView(layout) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + val selectedCollection = + (list.adapter as CollectionsListAdapter).getSelectedCollection() + val collection = storage.cachedTabCollections[selectedCollection] + val sessionList = onPositiveButtonClick.invoke(collection) + + MainScope().launch { + storage.addTabsToCollection(collection, sessionList) + } + + dialog.dismiss() + }.setNegativeButton(android.R.string.cancel) { dialog, _ -> + onNegativeButtonClick.invoke() + + dialog.cancel() + } + + val dialog = builder.create() + val collectionNames = + arrayOf(context.getString(R.string.tab_tray_add_new_collection)) + collections + val collectionsListAdapter = CollectionsListAdapter(collectionNames) { + dialog.dismiss() + showAddNewDialog(context, storage) + } + + list.apply { + layoutManager = LinearLayoutManager(context) + adapter = collectionsListAdapter + } + dialog.show() +} + +internal fun CollectionsDialog.showAddNewDialog( + context: Context, + collectionsStorage: TabCollectionStorage +) { + val layout = LayoutInflater.from(context).inflate(R.layout.name_collection_dialog, null) + val collectionNameEditText: EditText = layout.findViewById(R.id.collection_name) + + collectionNameEditText.setText( + context.getString( + R.string.create_collection_default_name, + collectionsStorage.cachedTabCollections.getDefaultCollectionNumber() + ) + ) + + AlertDialog.Builder(context) + .setTitle(R.string.tab_tray_add_new_collection) + .setView(layout).setPositiveButton(android.R.string.ok) { dialog, _ -> + val sessionList = onPositiveButtonClick.invoke(null) + + MainScope().launch { + storage.createCollection( + collectionNameEditText.text.toString(), + sessionList + ) + } + + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + onNegativeButtonClick.invoke() + dialog.cancel() + } + .create() + .show() + + collectionNameEditText.setSelection(0, collectionNameEditText.text.length) + collectionNameEditText.showKeyboard() +} 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 543c868e9d..0d031e77d8 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt @@ -4,19 +4,25 @@ package org.mozilla.fenix.tabstray +import android.content.Context 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.selector.normalTabs import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.tabstray.Tab +import org.mozilla.fenix.collections.CollectionsDialog +import org.mozilla.fenix.collections.show +import org.mozilla.fenix.components.TabCollectionStorage 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 +import org.mozilla.fenix.tabstray.ext.getTabSessionState /** * An interactor that helps with navigating to different parts of the app from the tabs tray. @@ -69,13 +75,15 @@ interface NavigationInteractor { */ @Suppress("LongParameterList") class DefaultNavigationInteractor( - private val tabsTrayStore: TabsTrayStore, + private val context: Context, private val browserStore: BrowserStore, private val navController: NavController, private val metrics: MetricController, private val dismissTabTray: () -> Unit, private val dismissTabTrayAndNavigateHome: (String) -> Unit, - private val bookmarksUseCase: BookmarksUseCase + private val bookmarksUseCase: BookmarksUseCase, + private val tabsTrayStore: TabsTrayStore, + private val collectionStorage: TabCollectionStorage ) : NavigationInteractor { override fun onTabTrayDismissed() { @@ -126,7 +134,25 @@ class DefaultNavigationInteractor( override fun onSaveToCollections(tabs: Collection) { metrics.track(Event.TabsTraySaveToCollectionPressed) - // TODO add this is a separate PR; it's quite a large change. + CollectionsDialog( + storage = collectionStorage, + onPositiveButtonClick = { existingCollection -> + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + + // If collection is null, a new one was created. + val event = if (existingCollection == null) { + Event.CollectionSaved(browserStore.state.normalTabs.size, tabs.size) + } else { + Event.CollectionTabsAdded(browserStore.state.normalTabs.size, tabs.size) + } + metrics.track(event) + + browserStore.getTabSessionState(tabs) + }, + onNegativeButtonClick = { + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + ).show(context) } override fun onSaveToBookmarks(tabs: Collection) { 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 a1ca35761e..e73c516c06 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -93,13 +93,15 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val navigationInteractor = DefaultNavigationInteractor( + context = requireContext(), tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, navController = findNavController(), metrics = requireComponents.analytics.metrics, dismissTabTray = ::dismissAllowingStateLoss, dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, - bookmarksUseCase = requireComponents.useCases.bookmarksUseCases + bookmarksUseCase = requireComponents.useCases.bookmarksUseCases, + collectionStorage = requireComponents.core.tabCollectionStorage ) tabsTrayController = DefaultTabsTrayController( diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt new file mode 100644 index 0000000000..c5577a13ea --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt @@ -0,0 +1,19 @@ +/* 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 mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab + +/** + * Find and extract a list [TabSessionState] from the [BrowserStore] using the IDs from [tabs]. + */ +fun BrowserStore.getTabSessionState(tabs: Collection): List { + return tabs.mapNotNull { + state.findTab(it.id) + } +} 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 3ddf4f2d4f..5ef2755123 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt @@ -4,11 +4,15 @@ package org.mozilla.fenix.tabstray +import android.content.Context import androidx.navigation.NavController import androidx.navigation.NavDirections import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.verify +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab as createStateTab @@ -17,6 +21,9 @@ import mozilla.components.concept.tabstray.Tab import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mozilla.fenix.collections.CollectionsDialog +import org.mozilla.fenix.collections.show +import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController @@ -32,19 +39,23 @@ class NavigationInteractorTest { private val dismissTabTray: () -> Unit = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: (String) -> Unit = mockk(relaxed = true) private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val collectionStorage: TabCollectionStorage = mockk(relaxed = true) @Before fun setup() { store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab))) tabsTrayStore = TabsTrayStore() navigationInteractor = DefaultNavigationInteractor( - tabsTrayStore, + context, store, navController, metrics, dismissTabTray, dismissTabTrayAndNavigateHome, - bookmarksUseCase + bookmarksUseCase, + tabsTrayStore, + collectionStorage ) } @@ -151,8 +162,13 @@ class NavigationInteractorTest { @Test fun `onSaveToCollections calls navigation on DefaultNavigationInteractor`() { + mockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt") + + every { any().show(any()) } answers { } navigationInteractor.onSaveToCollections(emptyList()) verify(exactly = 1) { metrics.track(Event.TabsTraySaveToCollectionPressed) } + + unmockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt") } @Test diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt new file mode 100644 index 0000000000..4c416870d0 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt @@ -0,0 +1,60 @@ +/* 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 io.mockk.mockk +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mozilla.fenix.tabstray.browser.createTab + +class BrowserStoreKtTest { + + @Test + fun `WHEN session is found THEN return it`() { + val store = BrowserStore( + initialState = BrowserState( + listOf( + TabSessionState(id = "tab1", mockk(), lastAccess = 3), + TabSessionState(id = "tab2", mockk(), lastAccess = 5) + ) + ) + ) + + val tabs = listOf( + createTab("tab1"), + createTab("tab2") + ) + + val result = store.getTabSessionState(tabs) + + assertEquals(3, result[0].lastAccess) + assertEquals(5, result[1].lastAccess) + } + + @Test + fun `WHEN session is not found THEN ignore it`() { + val store = BrowserStore( + initialState = BrowserState( + listOf( + TabSessionState(id = "tab2", mockk(), lastAccess = 5) + ) + ) + ) + + val tabs = listOf( + createTab("tab1"), + createTab("tab2") + ) + + val result = store.getTabSessionState(tabs) + + assertEquals(5, result[0].lastAccess) + assertEquals(1, result.size) + } +}