From dbfd5ffca0f54cf296657c44581ea1a3ecdd9dff Mon Sep 17 00:00:00 2001 From: MatthewTighe Date: Mon, 28 Mar 2022 13:13:07 -0700 Subject: [PATCH] for #24177: sync tabs when home is shown --- .../fenix/components/appstate/AppAction.kt | 7 + .../fenix/components/appstate/AppState.kt | 3 + .../components/appstate/AppStoreReducer.kt | 5 + .../org/mozilla/fenix/home/HomeFragment.kt | 25 ++ .../RecentSyncedTabFeature.kt | 122 +++++++++ .../controller/RecentSyncedTabController.kt | 50 ++++ .../interactor/RecentSyncedTabInteractor.kt | 25 ++ .../recentsyncedtabs/view/RecentSyncedTab.kt | 232 ++++++++++++++++++ .../home/recenttabs/RecentTabsListFeature.kt | 15 -- .../recenttabs/view/RecentTabViewHolder.kt | 59 ++++- .../fenix/home/recenttabs/view/RecentTabs.kt | 174 ------------- .../sessioncontrol/SessionControlAdapter.kt | 3 +- .../SessionControlInteractor.kt | 13 + .../fenix/tabstray/TabLayoutMediator.kt | 8 +- .../mozilla/fenix/components/AppStoreTest.kt | 21 ++ .../home/SessionControlInteractorTest.kt | 19 ++ .../home/blocklist/BlocklistMiddlewareTest.kt | 18 +- .../RecentSyncedTabFeatureTest.kt | 188 ++++++++++++++ .../DefaultRecentSyncedTabControllerTest.kt | 65 +++++ .../interactor/RecentVisitsInteractorTest.kt | 3 + .../fenix/tabstray/TabLayoutMediatorTest.kt | 17 ++ 21 files changed, 859 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeature.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/controller/RecentSyncedTabController.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/interactor/RecentSyncedTabInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/view/RecentSyncedTab.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt index 1af3c4c1490a..9a015ad36fc3 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt @@ -14,6 +14,7 @@ import org.mozilla.fenix.home.Mode import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory import org.mozilla.fenix.home.recentbookmarks.RecentBookmark +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem import org.mozilla.fenix.gleanplumb.Message @@ -63,6 +64,12 @@ sealed class AppAction : Action { val categoriesSelected: List ) : AppAction() object RemoveCollectionsPlaceholder : AppAction() + + /** + * Updates the [RecentSyncedTabState] with the given [state]. + */ + data class RecentSyncedTabStateChange(val state: RecentSyncedTabState) : AppAction() + /** * [Action]s related to interactions with the Messaging Framework. */ diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppState.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppState.kt index 5c226b14ef98..5a1305c3b0f2 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppState.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppState.kt @@ -15,6 +15,7 @@ import org.mozilla.fenix.home.Mode import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory import org.mozilla.fenix.home.recentbookmarks.RecentBookmark +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem import org.mozilla.fenix.gleanplumb.MessagingState @@ -32,6 +33,7 @@ import org.mozilla.fenix.gleanplumb.MessagingState * @property topSites The list of [TopSite] in the [HomeFragment]. * @property showCollectionPlaceholder If true, shows a placeholder when there are no collections. * @property recentTabs The list of recent [RecentTab] in the [HomeFragment]. + * @property recentSyncedTabState The [RecentSyncedTabState] in the [HomeFragment]. * @property recentBookmarks The list of recently saved [BookmarkNode]s to show on the [HomeFragment]. * @property recentHistory The list of [RecentlyVisitedItem]s. * @property pocketStories The list of currently shown [PocketRecommendedStory]s. @@ -48,6 +50,7 @@ data class AppState( val topSites: List = emptyList(), val showCollectionPlaceholder: Boolean = false, val recentTabs: List = emptyList(), + val recentSyncedTabState: RecentSyncedTabState = RecentSyncedTabState.None, val recentBookmarks: List = emptyList(), val recentHistory: List = emptyList(), val pocketStories: List = emptyList(), diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt index c716d4b80d45..39e508b042e1 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt @@ -75,6 +75,11 @@ internal object AppStoreReducer { recentTabs = state.recentTabs.filterOutTab(action.recentTab) ) } + is AppAction.RecentSyncedTabStateChange -> { + state.copy( + recentSyncedTabState = action.state + ) + } is AppAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks) is AppAction.RemoveRecentBookmark -> { state.copy(recentBookmarks = state.recentBookmarks.filterNot { it.url == action.recentBookmark.url }) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 3322ee93f985..26e1d3d22d8a 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -106,6 +106,8 @@ import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.recentbookmarks.RecentBookmarksFeature import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksController +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabFeature +import org.mozilla.fenix.home.recentsyncedtabs.controller.DefaultRecentSyncedTabController import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature import org.mozilla.fenix.home.recenttabs.controller.DefaultRecentTabsController import org.mozilla.fenix.home.recentvisits.RecentVisitsFeature @@ -167,6 +169,16 @@ class HomeFragment : Fragment() { } } + private val syncedTabFeature by lazy { + RecentSyncedTabFeature( + store = requireComponents.appStore, + context = requireContext(), + storage = requireComponents.backgroundServices.syncedTabsStorage, + accountManager = requireComponents.backgroundServices.accountManager, + lifecycleOwner = viewLifecycleOwner + ) + } + private var _sessionControlInteractor: SessionControlInteractor? = null private val sessionControlInteractor: SessionControlInteractor get() = _sessionControlInteractor!! @@ -178,6 +190,7 @@ class HomeFragment : Fragment() { private val topSitesFeature = ViewBoundFeatureWrapper() private val messagingFeature = ViewBoundFeatureWrapper() private val recentTabsListFeature = ViewBoundFeatureWrapper() + private val recentSyncedTabFeature = ViewBoundFeatureWrapper() private val recentBookmarksFeature = ViewBoundFeatureWrapper() private val historyMetadataFeature = ViewBoundFeatureWrapper() @@ -278,6 +291,14 @@ class HomeFragment : Fragment() { owner = viewLifecycleOwner, view = binding.root ) + + if (FeatureFlags.taskContinuityFeature) { + recentSyncedTabFeature.set( + feature = syncedTabFeature, + owner = viewLifecycleOwner, + view = binding.root + ) + } } if (requireContext().settings().showRecentBookmarksFeature) { @@ -340,6 +361,10 @@ class HomeFragment : Fragment() { store = components.core.store, appStore = components.appStore, ), + recentSyncedTabController = DefaultRecentSyncedTabController( + addNewTabUseCase = requireComponents.useCases.tabsUseCases.addTab, + navController = findNavController(), + ), recentBookmarksController = DefaultRecentBookmarksController( activity = activity, navController = findNavController(), diff --git a/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeature.kt b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeature.kt new file mode 100644 index 000000000000..6fd3ac923b67 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeature.kt @@ -0,0 +1,122 @@ +/* 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.home.recentsyncedtabs + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.SyncedTabsFeature +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.base.feature.LifecycleAwareFeature +import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.appstate.AppAction + +/** + * Delegate to handle layout updates and dispatch actions related to the recent synced tab. + * + * @property store Store to dispatch actions to when synced tabs are updated or errors encountered. + * @param accountManager Account manager used to retrieve synced tab state. + * @param context [Context] used for retrieving the sync engine storage state. + * @param storage Storage layer for synced tabs. + * @param lifecycleOwner View lifecycle owner to determine start/stop state for feature. + */ +@Suppress("LongParameterList") +class RecentSyncedTabFeature( + private val store: AppStore, + accountManager: FxaAccountManager, + context: Context, + storage: SyncedTabsStorage, + lifecycleOwner: LifecycleOwner, +) : SyncedTabsView, LifecycleAwareFeature { + private val syncedTabsFeature by lazy { + SyncedTabsFeature( + view = this, + context = context, + storage = storage, + accountManager = accountManager, + lifecycleOwner = lifecycleOwner, + onTabClicked = {} + ) + } + + override var listener: SyncedTabsView.Listener? = null + + override fun startLoading() { + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Loading) + ) + } + + override fun displaySyncedTabs(syncedTabs: List) { + val syncedTab = syncedTabs + .filterNot { it.device.isCurrentDevice || it.tabs.isEmpty() } + .maxByOrNull { it.device.lastAccessTime ?: 0 } + ?.let { + val tab = it.tabs.firstOrNull()?.active() ?: return + RecentSyncedTab( + deviceDisplayName = it.device.displayName, + title = tab.title, + url = tab.url, + iconUrl = tab.iconUrl + ) + } ?: return + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(syncedTab)) + ) + } + + // UI will either not be displayed if not authenticated (DefaultPresenter.start), + // or the display state will be tied directly to the success and error cases. + override fun stopLoading() = Unit + + override fun onError(error: SyncedTabsView.ErrorType) { + store.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None)) + } + + override fun start() { + syncedTabsFeature.start() + } + + override fun stop() { + syncedTabsFeature.stop() + } +} + +/** + * The state of the recent synced tab. + */ +sealed class RecentSyncedTabState { + /** + * There is no synced tab, or a user is not authenticated. + */ + object None : RecentSyncedTabState() + + /** + * A user is authenticated and the sync is running. + */ + object Loading : RecentSyncedTabState() + + /** + * A user is authenticated and the most recent synced tab has been found. + */ + data class Success(val tab: RecentSyncedTab) : RecentSyncedTabState() +} + +/** + * A tab that was recently viewed on a synced device. + * + * @param deviceDisplayName The device the tab was viewed on. + * @param title The title of the tab. + * @param url The url of the tab. + * @param iconUrl The url used to retrieve the icon of the tab. + */ +data class RecentSyncedTab( + val deviceDisplayName: String, + val title: String, + val url: String, + val iconUrl: String?, +) diff --git a/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/controller/RecentSyncedTabController.kt b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/controller/RecentSyncedTabController.kt new file mode 100644 index 000000000000..4617d598a7da --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/controller/RecentSyncedTabController.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.home.recentsyncedtabs.controller + +import androidx.navigation.NavController +import mozilla.components.feature.tabs.TabsUseCases +import org.mozilla.fenix.R +import org.mozilla.fenix.home.HomeFragmentDirections +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab +import org.mozilla.fenix.home.recentsyncedtabs.interactor.RecentSyncedTabInteractor +import org.mozilla.fenix.tabstray.Page + +/** + * An interface that handles the view manipulation of the recent synced tabs in the Home screen. + */ +interface RecentSyncedTabController { + /** + * @see [RecentSyncedTabInteractor.onRecentSyncedTabClicked] + */ + fun handleRecentSyncedTabClick(tab: RecentSyncedTab) + + /** + * @see [RecentSyncedTabInteractor.onRecentSyncedTabClicked] + */ + fun handleSyncedTabShowAllClicked() +} + +/** + * The default implementation of [RecentSyncedTabController]. + * + * @property addNewTabUseCase Use case to open the synced tab when clicked. + * @property navController [NavController] to navigate to synced tabs tray. + */ +class DefaultRecentSyncedTabController( + private val addNewTabUseCase: TabsUseCases.AddNewTabUseCase, + private val navController: NavController, +) : RecentSyncedTabController { + override fun handleRecentSyncedTabClick(tab: RecentSyncedTab) { + addNewTabUseCase.invoke(tab.url) + navController.navigate(R.id.browserFragment) + } + + override fun handleSyncedTabShowAllClicked() { + navController.navigate( + HomeFragmentDirections.actionGlobalTabsTrayFragment(page = Page.SyncedTabs) + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/interactor/RecentSyncedTabInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/interactor/RecentSyncedTabInteractor.kt new file mode 100644 index 000000000000..d00159f8da77 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/interactor/RecentSyncedTabInteractor.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.home.recentsyncedtabs.interactor + +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab + +/** + * Interface for recent synced tab related actions in the Home screen. + */ +interface RecentSyncedTabInteractor { + /** + * Opens the synced tab locally. Called when a user clicks on a recent synced tab. + * + * @param tab The recent synced tab that has been clicked. + */ + fun onRecentSyncedTabClicked(tab: RecentSyncedTab) + + /** + * Opens the tabs tray to the synced tab page. Called when a user clicks on the "See all synced + * tabs" button. + */ + fun onSyncedTabShowAllClicked() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/view/RecentSyncedTab.kt b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/view/RecentSyncedTab.kt new file mode 100644 index 000000000000..9b697678a46f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/view/RecentSyncedTab.kt @@ -0,0 +1,232 @@ +/* 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.home.recentsyncedtabs.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.PrimaryText +import org.mozilla.fenix.compose.SecondaryText +import org.mozilla.fenix.compose.ThumbnailCard +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.theme.Theme + +/** + * A recent synced tab card. + * + * @param tab The [RecentSyncedTab] to display. + * @param onRecentSyncedTabClick Invoked when the user clicks on the recent synced tab. + * @param onSeeAllSyncedTabsButtonClick Invoked when user clicks on the "See all" button in the synced tab card. + */ +@Suppress("LongMethod") +@Composable +fun RecentSyncedTab( + tab: RecentSyncedTab?, + onRecentSyncedTabClick: (RecentSyncedTab) -> Unit, + onSeeAllSyncedTabsButtonClick: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clickable { tab?.let { onRecentSyncedTabClick(tab) } }, + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.layer2, + elevation = 6.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (tab == null) { + RecentTabImagePlaceholder() + } else { + ThumbnailCard( + url = tab.url, + key = tab.url.hashCode().toString(), + modifier = Modifier + .size(108.dp, 80.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxHeight() + ) { + if (tab == null) { + RecentTabTitlePlaceholder() + } else { + PrimaryText( + text = tab.title, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (tab == null) { + Box( + modifier = Modifier + .background(FirefoxTheme.colors.layer3) + .size(18.dp) + ) + } else { + Image( + painter = painterResource(R.drawable.ic_synced_tabs), + contentDescription = stringResource( + R.string.recent_tabs_synced_device_icon_content_description + ), + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + if (tab == null) { + TextLinePlaceHolder() + } else { + SecondaryText( + text = tab.deviceDisplayName, + fontSize = 12.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onSeeAllSyncedTabsButtonClick, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = if (tab == null) { + FirefoxTheme.colors.layer3 + } else { + FirefoxTheme.colors.actionSecondary + } + ), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ), + modifier = Modifier + .height(36.dp) + .fillMaxWidth() + ) { + if (tab != null) { + Text( + text = stringResource(R.string.recent_tabs_see_all_synced_tabs_button_text), + textAlign = TextAlign.Center, + color = FirefoxTheme.colors.textActionSecondary + ) + } + } + } + } +} + +/** + * A placeholder for a recent tab image. + */ +@Composable +private fun RecentTabImagePlaceholder() { + Box( + modifier = Modifier + .size(108.dp, 80.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color = FirefoxTheme.colors.layer3) + ) +} + +/** + * A placeholder for a tab title. + */ +@Composable +private fun RecentTabTitlePlaceholder() { + Column { + TextLinePlaceHolder() + + Spacer(modifier = Modifier.height(8.dp)) + + TextLinePlaceHolder() + } +} + +/** + * A placeholder for a single line of text. + */ +@Composable +private fun TextLinePlaceHolder() { + Box( + modifier = Modifier + .height(12.dp) + .fillMaxWidth() + .background(FirefoxTheme.colors.layer3) + ) +} + +@Preview +@Composable +private fun LoadedRecentSyncedTab() { + val tab = RecentSyncedTab( + deviceDisplayName = "Firefox on MacBook", + title = "This is a long site title", + url = "https://mozilla.org", + iconUrl = "https://mozilla.org", + ) + FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) { + RecentSyncedTab( + tab = tab, + onRecentSyncedTabClick = {}, + onSeeAllSyncedTabsButtonClick = {}, + ) + } +} + +@Preview +@Composable +private fun LoadingRecentSyncedTab() { + FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) { + RecentSyncedTab( + tab = null, + onRecentSyncedTabClick = {}, + onSeeAllSyncedTabsButtonClick = {}, + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt index ff7ece524b94..67b61b6d6bdd 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt @@ -48,21 +48,6 @@ sealed class RecentTab { */ data class Tab(val state: TabSessionState) : RecentTab() - /** - * A tab that was recently viewed on a synced device. - * - * @param deviceDisplayName The device the tab was viewed on. - * @param title The title of the tab. - * @param url The url of the tab. - * @param previewImageUrl The url used to retrieve the preview image of the tab. - */ - data class SyncedTab( - val deviceDisplayName: String, - val title: String, - val url: String, - val previewImageUrl: String?, - ) : RecentTab() - /** * A search term group that was recently viewed * diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt index 6aedfa54ebc1..875dcbca5c1d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt @@ -5,26 +5,39 @@ package org.mozilla.fenix.home.recenttabs.view import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.LifecycleOwner import mozilla.components.lib.state.ext.observeAsComposableState +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.components import org.mozilla.fenix.compose.ComposeViewHolder +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor +import org.mozilla.fenix.home.recentsyncedtabs.interactor.RecentSyncedTabInteractor +import org.mozilla.fenix.home.recentsyncedtabs.view.RecentSyncedTab /** * View holder for a recent tab item. * * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. - * @param interactor [RecentTabInteractor] which will have delegated to all user interactions. + * @param recentTabInteractor [RecentTabInteractor] which will have delegated to all user recent + * tab interactions. + * @param recentSyncedTabInteractor [RecentSyncedTabInteractor] which will have delegated to all user + * recent synced tab interactions. */ class RecentTabViewHolder( composeView: ComposeView, viewLifecycleOwner: LifecycleOwner, - private val interactor: RecentTabInteractor + private val recentTabInteractor: RecentTabInteractor, + private val recentSyncedTabInteractor: RecentSyncedTabInteractor, ) : ComposeViewHolder(composeView, viewLifecycleOwner) { init { @@ -40,17 +53,41 @@ class RecentTabViewHolder( @Composable override fun Content() { val recentTabs = components.appStore.observeAsComposableState { state -> state.recentTabs } + val recentSyncedTabState = components.appStore.observeAsComposableState { state -> state.recentSyncedTabState } - RecentTabs( - recentTabs = recentTabs.value ?: emptyList(), - onRecentTabClick = { interactor.onRecentTabClicked(it) }, - onRecentSearchGroupClick = { interactor.onRecentSearchGroupClicked(it) }, - menuItems = listOf( - RecentTabMenuItem( - title = stringResource(id = R.string.recent_tab_menu_item_remove), - onClick = { tab -> interactor.onRemoveRecentTab(tab) } + Column { + RecentTabs( + recentTabs = recentTabs.value ?: emptyList(), + onRecentTabClick = { recentTabInteractor.onRecentTabClicked(it) }, + onRecentSearchGroupClick = { recentTabInteractor.onRecentSearchGroupClicked(it) }, + menuItems = listOf( + RecentTabMenuItem( + title = stringResource(id = R.string.recent_tab_menu_item_remove), + onClick = { tab -> recentTabInteractor.onRemoveRecentTab(tab) } + ) ) ) - ) + + recentSyncedTabState.value?.let { + if (FeatureFlags.taskContinuityFeature && it != RecentSyncedTabState.None) { + Spacer(modifier = Modifier.height(8.dp)) + + val syncedTab = when (it) { + RecentSyncedTabState.None, + RecentSyncedTabState.Loading -> null + is RecentSyncedTabState.Success -> it.tab + } + RecentSyncedTab( + tab = syncedTab, + onRecentSyncedTabClick = { tab -> + recentSyncedTabInteractor.onRecentSyncedTabClicked(tab) + }, + onSeeAllSyncedTabsButtonClick = { + recentSyncedTabInteractor.onSyncedTabShowAllClicked() + }, + ) + } + } + } } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt index aa8fe4c35280..c9b994704d70 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -27,8 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem @@ -49,7 +46,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -57,7 +53,6 @@ import mozilla.components.browser.icons.compose.Loader import mozilla.components.browser.icons.compose.Placeholder import mozilla.components.browser.icons.compose.WithIcon import mozilla.components.ui.colors.PhotonColors -import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.components import org.mozilla.fenix.compose.Image @@ -74,18 +69,13 @@ import org.mozilla.fenix.theme.FirefoxTheme * @param menuItems List of [RecentTabMenuItem] shown long clicking a [RecentTab]. * @param onRecentTabClick Invoked when the user clicks on a recent tab. * @param onRecentSearchGroupClick Invoked when the user clicks on a recent search group. - * @param onRecentSyncedTabClick Invoked when the user clicks on the recent synced tab. - * @param onSyncedTabSeeAllButtonClick Invoked when user clicks on the "See all" button in the synced tab card. */ @Composable -@Suppress("LongParameterList") fun RecentTabs( recentTabs: List, menuItems: List, onRecentTabClick: (String) -> Unit = {}, onRecentSearchGroupClick: (String) -> Unit = {}, - onRecentSyncedTabClick: (RecentTab.SyncedTab) -> Unit = {}, - onSyncedTabSeeAllButtonClick: () -> Unit = {}, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -110,15 +100,6 @@ fun RecentTabs( ) } } - is RecentTab.SyncedTab -> { - if (FeatureFlags.taskContinuityFeature) { - RecentSyncedTabItem( - tab, - onRecentSyncedTabClick, - onSyncedTabSeeAllButtonClick, - ) - } - } } } } @@ -276,124 +257,6 @@ private fun RecentSearchGroupItem( } } -/** - * A recent synced tab. - * - * @param tab Optional synced tab. If null, displays placeholders. - * @param onRecentSyncedTabClick Invoked when item is clicked. - * @param onSeeAllButtonClick Invoked when "See all" button is clicked. - */ -@Suppress("LongMethod") -@Composable -private fun RecentSyncedTabItem( - tab: RecentTab.SyncedTab?, - onRecentSyncedTabClick: (RecentTab.SyncedTab) -> Unit, - onSeeAllButtonClick: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clickable { tab?.let { onRecentSyncedTabClick(tab) } }, - shape = RoundedCornerShape(8.dp), - backgroundColor = FirefoxTheme.colors.layer2, - elevation = 6.dp - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - if (tab == null) { - RecentTabImagePlaceholder() - } else { - ThumbnailCard( - url = tab.url, - key = tab.url.hashCode().toString(), - modifier = Modifier - .size(108.dp, 80.dp) - .clip(RoundedCornerShape(8.dp)) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxHeight() - ) { - if (tab == null) { - RecentTabTitlePlaceholder() - } else { - PrimaryText( - text = tab.title, - fontSize = 14.sp, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - if (tab == null) { - Box( - modifier = Modifier - .background(FirefoxTheme.colors.layer3) - .size(18.dp) - ) - } else { - Image( - painter = painterResource(R.drawable.ic_synced_tabs), - contentDescription = stringResource( - R.string.recent_tabs_synced_device_icon_content_description - ), - modifier = Modifier.size(18.dp, 18.dp) - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - if (tab == null) { - TextLinePlaceHolder() - } else { - SecondaryText( - text = tab.deviceDisplayName, - fontSize = 12.sp, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - } - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = onSeeAllButtonClick, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (tab == null) { - FirefoxTheme.colors.layer3 - } else { - FirefoxTheme.colors.actionSecondary - } - ), - elevation = ButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp - ), - modifier = Modifier - .height(36.dp) - .fillMaxWidth() - ) { - if (tab != null) { - Text( - text = stringResource(R.string.recent_tabs_see_all_synced_tabs_button_text), - textAlign = TextAlign.Center, - color = FirefoxTheme.colors.textActionSecondary - ) - } - } - } - } -} - /** * A recent tab image. * @@ -438,19 +301,6 @@ fun RecentTabImage( } } -/** - * A placeholder for a recent tab image. - */ -@Composable -private fun RecentTabImagePlaceholder() { - Box( - modifier = Modifier - .size(108.dp, 80.dp) - .clip(RoundedCornerShape(8.dp)) - .background(color = FirefoxTheme.colors.layer3) - ) -} - /** * Menu shown for a [RecentTab.Tab]. * @@ -551,27 +401,3 @@ private fun RecentTabIcon( } } } - -/** - * A placeholder for a tab title. - */ -@Composable -private fun RecentTabTitlePlaceholder() { - Column { - TextLinePlaceHolder() - - Spacer(modifier = Modifier.height(8.dp)) - - TextLinePlaceHolder() - } -} - -@Composable -private fun TextLinePlaceHolder() { - Box( - modifier = Modifier - .height(12.dp) - .fillMaxWidth() - .background(FirefoxTheme.colors.layer3) - ) -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index 3c55059f76df..f8ca42f26c5b 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -248,7 +248,8 @@ class SessionControlAdapter( RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder( composeView = ComposeView(parent.context), viewLifecycleOwner = viewLifecycleOwner, - interactor = interactor + recentTabInteractor = interactor, + recentSyncedTabInteractor = interactor, ) RecentlyVisitedViewHolder.LAYOUT_ID -> return RecentlyVisitedViewHolder( composeView = ComposeView(parent.context), diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index bc86cd9c7c1f..2c8ade6b4b64 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -17,9 +17,12 @@ import org.mozilla.fenix.home.pocket.PocketStoriesInteractor import org.mozilla.fenix.home.recentbookmarks.RecentBookmark import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.controller.RecentTabController +import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor +import org.mozilla.fenix.home.recentsyncedtabs.interactor.RecentSyncedTabInteractor import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController @@ -253,6 +256,7 @@ interface MessageCardInteractor { class SessionControlInteractor( private val controller: SessionControlController, private val recentTabController: RecentTabController, + private val recentSyncedTabController: RecentSyncedTabController, private val recentBookmarksController: RecentBookmarksController, private val recentVisitsController: RecentVisitsController, private val pocketStoriesController: PocketStoriesController @@ -263,6 +267,7 @@ class SessionControlInteractor( ToolbarInteractor, MessageCardInteractor, RecentTabInteractor, + RecentSyncedTabInteractor, RecentBookmarksInteractor, RecentVisitsInteractor, CustomizeHomeIteractor, @@ -384,6 +389,14 @@ class SessionControlInteractor( recentTabController.handleRecentTabRemoved(tab) } + override fun onRecentSyncedTabClicked(tab: RecentSyncedTab) { + recentSyncedTabController.handleRecentSyncedTabClick(tab) + } + + override fun onSyncedTabShowAllClicked() { + recentSyncedTabController.handleSyncedTabShowAllClicked() + } + override fun onRecentBookmarkClicked(bookmark: RecentBookmark) { recentBookmarksController.handleBookmarkClicked(bookmark) } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt index d68c9d26fae3..d0b9719ed5f5 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt @@ -14,6 +14,7 @@ import org.mozilla.fenix.GleanMetrics.TabsTray import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_NORMAL_TABS import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_PRIVATE_TABS +import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_SYNCED_TABS import org.mozilla.fenix.utils.Do /** @@ -46,9 +47,10 @@ class TabLayoutMediator( @VisibleForTesting internal fun selectActivePage() { val selectedPagerPosition = - when (browsingModeManager.mode.isPrivate) { - true -> POSITION_PRIVATE_TABS - false -> POSITION_NORMAL_TABS + when { + browsingModeManager.mode.isPrivate -> POSITION_PRIVATE_TABS + tabsTrayStore.state.selectedPage == Page.SyncedTabs -> POSITION_SYNCED_TABS + else -> POSITION_NORMAL_TABS } selectTabAtPosition(selectedPagerPosition) diff --git a/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt b/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt index 2681c15feaf6..49813dfff881 100644 --- a/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt @@ -36,6 +36,8 @@ import org.mozilla.fenix.home.pocket.POCKET_STORIES_TO_SHOW_COUNT import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory import org.mozilla.fenix.home.recentbookmarks.RecentBookmark +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup @@ -150,6 +152,25 @@ class AppStoreTest { assertEquals(listOf(group1, group3, highlight), appStore.state.recentHistory) } + @Test + fun `GIVEN initial state WHEN recent synced tab state is changed THEN state updated`() = runBlocking { + appStore = AppStore( + AppState( + recentSyncedTabState = RecentSyncedTabState.None + ) + ) + + val loading = RecentSyncedTabState.Loading + appStore.dispatch(AppAction.RecentSyncedTabStateChange(loading)).join() + assertEquals(loading, appStore.state.recentSyncedTabState) + + val recentSyncedTab = RecentSyncedTab("device name", "title", "url", null) + val success = RecentSyncedTabState.Success(recentSyncedTab) + appStore.dispatch(AppAction.RecentSyncedTabStateChange(success)).join() + assertEquals(success, appStore.state.recentSyncedTabState) + assertEquals(recentSyncedTab, (appStore.state.recentSyncedTabState as RecentSyncedTabState.Success).tab) + } + @Test fun `Test changing the history metadata in AppStore`() = runBlocking { assertEquals(0, appStore.state.recentHistory.size) diff --git a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt index 195966ff654f..b4ffd1b2a23a 100644 --- a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt @@ -18,6 +18,8 @@ import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketStoriesController import org.mozilla.fenix.home.recentbookmarks.RecentBookmark import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab +import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController @@ -27,6 +29,7 @@ class SessionControlInteractorTest { private val controller: DefaultSessionControlController = mockk(relaxed = true) private val recentTabController: RecentTabController = mockk(relaxed = true) + private val recentSyncedTabController: RecentSyncedTabController = mockk(relaxed = true) private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true) private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true) @@ -40,6 +43,7 @@ class SessionControlInteractorTest { interactor = SessionControlInteractor( controller, recentTabController, + recentSyncedTabController, recentBookmarksController, recentVisitsController, pocketStoriesController @@ -171,6 +175,21 @@ class SessionControlInteractorTest { verify { recentTabController.handleRecentTabShowAllClicked() } } + @Test + fun `WHEN recent synced tab is clicked THEN the tab is handled`() { + val tab: RecentSyncedTab = mockk() + interactor.onRecentSyncedTabClicked(tab) + + verify { recentSyncedTabController.handleRecentSyncedTabClick(tab) } + } + + @Test + fun `WHEN recent synced tabs show all is clicked THEN show all synced tabs is handled`() { + interactor.onSyncedTabShowAllClicked() + + verify { recentSyncedTabController.handleSyncedTabShowAllClicked() } + } + @Test fun `WHEN a recently saved bookmark is clicked THEN the selected bookmark is handled`() { val bookmark = RecentBookmark() diff --git a/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt b/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt index 5c10a99a21be..622d5a566048 100644 --- a/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt @@ -43,7 +43,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -69,7 +69,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -95,7 +95,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -121,7 +121,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -149,7 +149,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = updatedRecentTabs, recentBookmarks = updatedBookmarks, - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -184,7 +184,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = updatedRecentTabs, recentBookmarks = updatedBookmarks, - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -236,7 +236,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -263,7 +263,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() @@ -290,7 +290,7 @@ class BlocklistMiddlewareTest { showCollectionPlaceholder = store.state.showCollectionPlaceholder, recentTabs = store.state.recentTabs, recentBookmarks = listOf(updatedBookmark), - recentHistory = store.state.recentHistory + recentHistory = store.state.recentHistory, ) ).joinBlocking() diff --git a/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt new file mode 100644 index 000000000000..da336e2b11ab --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt @@ -0,0 +1,188 @@ +/* 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.home.recentsyncedtabs + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.browser.storage.sync.Tab +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.concept.sync.Device +import mozilla.components.concept.sync.DeviceType +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import mozilla.components.service.fxa.manager.FxaAccountManager +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.appstate.AppAction + +class RecentSyncedTabFeatureTest { + private val earliestTime = 100L + private val earlierTime = 250L + private val timeNow = 500L + private val currentDevice = Device( + id = "currentId", + displayName = "currentDevice", + deviceType = DeviceType.MOBILE, + isCurrentDevice = true, + lastAccessTime = timeNow, + capabilities = listOf(), + subscriptionExpired = false, + subscription = null + ) + private val deviceAccessed1 = Device( + id = "id1", + displayName = "device1", + deviceType = DeviceType.DESKTOP, + isCurrentDevice = false, + lastAccessTime = earliestTime, + capabilities = listOf(), + subscriptionExpired = false, + subscription = null + ) + private val deviceAccessed2 = Device( + id = "id2", + displayName = "device2", + deviceType = DeviceType.DESKTOP, + isCurrentDevice = false, + lastAccessTime = earlierTime, + capabilities = listOf(), + subscriptionExpired = false, + subscription = null + ) + + private val store: AppStore = mockk() + private val accountManager: FxaAccountManager = mockk() + + private lateinit var feature: RecentSyncedTabFeature + + @Before + fun setup() { + every { store.dispatch(any()) } returns mockk() + + feature = RecentSyncedTabFeature( + store = store, + accountManager = accountManager, + context = mockk(), + storage = mockk(), + lifecycleOwner = mockk(), + ) + } + + @Test + fun `WHEN loading is started THEN loading state is dispatched`() { + feature.startLoading() + + verify { store.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Loading)) } + } + + @Test + fun `WHEN empty synced tabs are displayed THEN no action is dispatched`() { + feature.displaySyncedTabs(listOf()) + + verify(exactly = 0) { store.dispatch(any()) } + } + + @Test + fun `WHEN displaying synced tabs THEN first active tab is used`() { + val tab = createActiveTab("title", "https://mozilla.org", null) + val displayedTabs = listOf(SyncedDeviceTabs(deviceAccessed1, listOf(tab))) + + feature.displaySyncedTabs(displayedTabs) + + val expectedTab = tab.toRecentSyncedTab(deviceAccessed1) + + verify { + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTab)) + ) + } + } + + @Test + fun `WHEN displaying synced tabs THEN current device is filtered out`() { + val localTab = createActiveTab("local", "https://local.com", null) + val remoteTab = createActiveTab("remote", "https://mozilla.org", null) + val displayedTabs = listOf( + SyncedDeviceTabs(currentDevice, listOf(localTab)), + SyncedDeviceTabs(deviceAccessed1, listOf(remoteTab)) + ) + + feature.displaySyncedTabs(displayedTabs) + + val expectedTab = remoteTab.toRecentSyncedTab(deviceAccessed1) + + verify { + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTab)) + ) + } + } + + @Test + fun `WHEN displaying synced tabs THEN any devices with empty tabs list are filtered out`() { + val remoteTab = createActiveTab("remote", "https://mozilla.org", null) + val displayedTabs = listOf( + SyncedDeviceTabs(deviceAccessed2, listOf()), + SyncedDeviceTabs(deviceAccessed1, listOf(remoteTab)) + ) + + feature.displaySyncedTabs(displayedTabs) + + val expectedTab = remoteTab.toRecentSyncedTab(deviceAccessed1) + + verify { + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTab)) + ) + } + } + + @Test + fun `WHEN displaying synced tabs THEN most recently accessed device is used`() { + val firstTab = createActiveTab("first", "https://local.com", null) + val secondTab = createActiveTab("remote", "https://mozilla.org", null) + val displayedTabs = listOf( + SyncedDeviceTabs(deviceAccessed1, listOf(firstTab)), + SyncedDeviceTabs(deviceAccessed2, listOf(secondTab)) + ) + + feature.displaySyncedTabs(displayedTabs) + + val expectedTab = secondTab.toRecentSyncedTab(deviceAccessed2) + + verify { + store.dispatch( + AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTab)) + ) + } + } + + @Test + fun `WHEN error is received THEN action dispatched with empty synced state`() { + feature.onError(SyncedTabsView.ErrorType.NO_TABS_AVAILABLE) + + verify { store.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None)) } + } + + private fun createActiveTab( + title: String = "title", + url: String = "url", + iconUrl: String? = null, + ): Tab { + val tab = mockk() + val tabEntry = TabEntry(title, url, iconUrl) + every { tab.active() } returns tabEntry + return tab + } + + private fun Tab.toRecentSyncedTab(device: Device) = RecentSyncedTab( + deviceDisplayName = device.displayName, + title = this.active().title, + url = this.active().url, + iconUrl = this.active().iconUrl + ) +} diff --git a/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt new file mode 100644 index 000000000000..3ace8d4b471c --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt @@ -0,0 +1,65 @@ +/* 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.home.recentsyncedtabs.controller + +import androidx.navigation.NavController +import androidx.navigation.NavDirections +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import mozilla.components.feature.tabs.TabsUseCases +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.home.HomeFragmentDirections +import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab +import org.mozilla.fenix.tabstray.Page + +class DefaultRecentSyncedTabControllerTest { + + private val addTabUseCase: TabsUseCases.AddNewTabUseCase = mockk() + private val navController: NavController = mockk() + + private lateinit var controller: RecentSyncedTabController + + @Before + fun setup() { + controller = DefaultRecentSyncedTabController(addTabUseCase, navController) + } + + @Test + fun `WHEN synced tab clicked THEN tab add and navigate to browser`() { + val url = "https://mozilla.org" + val tab = RecentSyncedTab( + deviceDisplayName = "display", + title = "title", + url = url, + iconUrl = null + ) + + every { addTabUseCase.invoke(any()) } just runs + every { navController.navigate(any()) } just runs + + controller.handleRecentSyncedTabClick(tab) + + verify { addTabUseCase.invoke(url) } + verify { navController.navigate(R.id.browserFragment) } + } + + @Test + fun `WHEN synced tab show all clicked THEN navigate to synced tabs tray`() { + every { navController.navigate(any()) } just runs + + controller.handleSyncedTabShowAllClicked() + + verify { + navController.navigate( + HomeFragmentDirections.actionGlobalTabsTrayFragment(page = Page.SyncedTabs) + ) + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt index ba6dbf664ceb..6f6687c5ba8c 100644 --- a/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt @@ -13,6 +13,7 @@ import org.junit.Before import org.junit.Test import org.mozilla.fenix.home.pocket.PocketStoriesController import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController +import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight @@ -24,6 +25,7 @@ class RecentVisitsInteractorTest { private val defaultSessionControlController: DefaultSessionControlController = mockk(relaxed = true) private val recentTabController: RecentTabController = mockk(relaxed = true) + private val recentSyncedTabController: RecentSyncedTabController = mockk(relaxed = true) private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true) private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true) private val recentVisitsController: RecentVisitsController = mockk(relaxed = true) @@ -35,6 +37,7 @@ class RecentVisitsInteractorTest { interactor = SessionControlInteractor( defaultSessionControlController, recentTabController, + recentSyncedTabController, recentBookmarksController, recentVisitsController, pocketStoriesController diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt index 8a547d8be2b8..69bc0870d627 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt @@ -28,8 +28,11 @@ class TabLayoutMediatorTest { fun `page to normal tab position when mode is also normal`() { val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore) + val mockState: TabsTrayState = mockk() every { modeManager.mode }.answers { BrowsingMode.Normal } every { tabLayout.getTabAt(POSITION_NORMAL_TABS) }.answers { tab } + every { tabsTrayStore.state } returns mockState + every { mockState.selectedPage } returns Page.NormalTabs mediator.selectActivePage() @@ -51,6 +54,20 @@ class TabLayoutMediatorTest { verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_PRIVATE_TABS))) } } + @Test + fun `page to synced tabs when selected page is also synced tabs`() { + val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore) + + val mockState: TabsTrayState = mockk() + every { modeManager.mode }.answers { BrowsingMode.Normal } + every { tabsTrayStore.state } returns mockState + every { mockState.selectedPage } returns Page.SyncedTabs + + mediator.selectActivePage() + + verify { viewPager.setCurrentItem(POSITION_SYNCED_TABS, false) } + } + @Test fun `selectTabAtPosition will dispatch the correct TabsTrayStore action`() { val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)