From e4690268051bdd4f4ebfb17f699eca54c041c8f2 Mon Sep 17 00:00:00 2001 From: Mugurell Date: Wed, 23 Jun 2021 20:14:55 +0300 Subject: [PATCH] [fenix] For https://github.com/mozilla-mobile/fenix/issues/19933 - Show a media tab item on homescreen for the last tab with media --- .../java/org/mozilla/fenix/components/Core.kt | 4 +- .../org/mozilla/fenix/ext/BrowserState.kt | 37 ++++- .../home/recenttabs/RecentTabsListFeature.kt | 33 ++-- .../recenttabs/view/RecentTabViewDecorator.kt | 132 +++++++++++++++ .../recenttabs/view/RecentTabViewHolder.kt | 4 +- .../sessioncontrol/SessionControlAdapter.kt | 15 +- .../home/sessioncontrol/SessionControlView.kt | 37 ++++- .../main/res/layout/recent_tabs_list_row.xml | 1 - .../org/mozilla/fenix/ext/BrowserStateTest.kt | 157 ++++++++++++++---- .../fenix/home/RecentTabsListFeatureTest.kt | 100 ++++++++++- .../view/RecentTabViewDecoratorTest.kt | 142 ++++++++++++++++ .../sessioncontrol/SessionControlViewTest.kt | 83 +++++++++ 12 files changed, 673 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/components/Core.kt b/app/src/main/java/org/mozilla/fenix/components/Core.kt index 62227807f7..3154147814 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Core.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Core.kt @@ -33,6 +33,7 @@ import mozilla.components.feature.customtabs.store.CustomTabsServiceStore import mozilla.components.feature.downloads.DownloadMiddleware import mozilla.components.feature.logins.exceptions.LoginExceptionStorage import mozilla.components.feature.media.MediaSessionFeature +import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware import mozilla.components.feature.media.middleware.RecordingDevicesMiddleware import mozilla.components.feature.prompts.PromptMiddleware import mozilla.components.feature.pwa.ManifestStorage @@ -208,7 +209,8 @@ class Core( ), RecordingDevicesMiddleware(context), PromptMiddleware(), - AdsTelemetryMiddleware(adsTelemetry) + AdsTelemetryMiddleware(adsTelemetry), + LastMediaAccessMiddleware() ) if (FeatureFlags.historyMetadataFeature) { diff --git a/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt b/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt index 5630172a61..7e2b431ae6 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt @@ -4,21 +4,40 @@ package org.mozilla.fenix.ext -import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.selector.selectedNormalTab import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState /** - * Returns the currently selected tab if there's one as a list. + * Get the last opened normal tab and the last tab with in progress media, if available. * - * @return A list of the currently selected tab or an empty list. + * @return A list of the last opened tab and the last tab with in progress media + * if distinct and available or an empty list. */ fun BrowserState.asRecentTabs(): List { - val tab = selectedTab - - return if (tab != null && !tab.content.private) { - listOfNotNull(tab) - } else { - emptyList() + return mutableListOf().apply { + val lastOpenedNormalTab = lastOpenedNormalTab + lastOpenedNormalTab?.let { add(it) } + inProgressMediaTab + ?.takeUnless { it == lastOpenedNormalTab } + ?.let { + add(it) + } } } + +/** + * Get the selected normal tab or the last accessed normal tab + * if there is no selected tab or the selected tab is a private one. + */ +val BrowserState.lastOpenedNormalTab: TabSessionState? + get() = selectedNormalTab ?: normalTabs.maxByOrNull { it.lastAccess } + +/** + * Get the last tab with in progress media. + */ +val BrowserState.inProgressMediaTab: TabSessionState? + get() = normalTabs + .filter { it.lastMediaAccess > 0 } + .maxByOrNull { it.lastMediaAccess } 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 96b40f46b2..658945ea48 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 @@ -7,14 +7,13 @@ package org.mozilla.fenix.home.recenttabs import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map import mozilla.components.browser.state.selector.normalTabs -import mozilla.components.browser.state.selector.selectedNormalTab -import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.lib.state.helpers.AbstractBinding import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import org.mozilla.fenix.ext.asRecentTabs +import org.mozilla.fenix.ext.lastOpenedNormalTab import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentStore @@ -29,20 +28,20 @@ class RecentTabsListFeature( ) : AbstractBinding(browserStore) { override suspend fun onState(flow: Flow) { - flow.map { it.selectedTab } - .ifAnyChanged { arrayOf(it?.id, it?.content?.title, it?.content?.icon) } - .collect { _ -> - // Attempt to get the selected normal tab or the last accessed normal tab - // if there is no selected tab or the selected tab is a private one. - val selectedTab = browserStore.state.selectedNormalTab - ?: browserStore.state.normalTabs.maxByOrNull { it.lastAccess } - val recentTabsList = if (selectedTab != null) { - listOf(selectedTab) - } else { - emptyList() - } - - homeStore.dispatch(HomeFragmentAction.RecentTabsChange(recentTabsList)) + flow + // Listen for changes regarding the currently selected tab and the in progress media tab + // and also for changes (close, undo) in normal tabs that could involve these. + .ifAnyChanged { + val lastOpenedNormalTab = it.lastOpenedNormalTab + arrayOf( + lastOpenedNormalTab?.id, + lastOpenedNormalTab?.content?.title, + lastOpenedNormalTab?.content?.icon, + it.normalTabs + ) + } + .collect { + homeStore.dispatch(HomeFragmentAction.RecentTabsChange(browserStore.state.asRecentTabs())) } } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt new file mode 100644 index 0000000000..56e3950d9e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt @@ -0,0 +1,132 @@ +/* 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.recenttabs.view + +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.ktx.android.util.dpToPx +import org.mozilla.fenix.R + +private const val TOP_MARGIN_DP = 1 + +/** + * All possible positions of a recent tab in relation to others when shown in the "Jump back in" section. + */ +enum class RecentTabsItemPosition { + /** + * This is the only tab to be shown in this section. + */ + SINGLE, + + /** + * This item is to be shown at the top of the section with others below it. + */ + TOP, + + /** + * This item is to be shown between others in this section. + */ + MIDDLE, + + /** + * This item is to be shown at the bottom of the section with others above it. + */ + BOTTOM +} + +/** + * Helpers for setting various layout properties for the view from a [RecentTabViewHolder]. + * + * Depending on the provided [RecentTabsItemPosition]: + * - sets a different background so that the entire section possibly containing + * more such items would have rounded corners but sibling items not. + * - sets small margins for the items so that there's a clear separation between siblings + */ +sealed class RecentTabViewDecorator { + /** + * Apply the decoration to [itemView]. + */ + abstract operator fun invoke(itemView: View): View + + companion object { + /** + * Get the appropriate decorator to set view background / margins depending on the position + * of that view in the recent tabs section. + */ + fun forPosition(position: RecentTabsItemPosition) = when (position) { + RecentTabsItemPosition.SINGLE -> SingleTabDecoration + RecentTabsItemPosition.TOP -> TopTabDecoration + RecentTabsItemPosition.MIDDLE -> MiddleTabDecoration + RecentTabsItemPosition.BOTTOM -> BottomTabDecoration + } + } + + /** + * Decorator for a view shown in the recent tabs section that will update it to express + * that that item is the single one shown in this section. + */ + object SingleTabDecoration : RecentTabViewDecorator() { + override fun invoke(itemView: View): View { + val context = itemView.context + + itemView.background = + AppCompatResources.getDrawable(context, R.drawable.home_list_row_background) + + return itemView + } + } + + /** + * Decorator for a view shown in the recent tabs section that will update it to express + * that this is an item shown at the top of the section and there are others below it. + */ + object TopTabDecoration : RecentTabViewDecorator() { + override fun invoke(itemView: View): View { + val context = itemView.context + + itemView.background = + AppCompatResources.getDrawable(context, R.drawable.rounded_top_corners) + + return itemView + } + } + + /** + * Decorator for a view shown in the recent tabs section that will update it to express + * that this is an item shown has other recents tabs to be shown on top or below it. + */ + object MiddleTabDecoration : RecentTabViewDecorator() { + override fun invoke(itemView: View): View { + val context = itemView.context + + itemView.setBackgroundColor(context.getColorFromAttr(R.attr.above)) + + (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = + TOP_MARGIN_DP.dpToPx(context.resources.displayMetrics) + + return itemView + } + } + + /** + * Decorator for a view shown in the recent tabs section that will update it to express + * that this is an item shown at the bottom of the section and there are others above it. + */ + object BottomTabDecoration : RecentTabViewDecorator() { + override fun invoke(itemView: View): View { + val context = itemView.context + + itemView.background = + AppCompatResources.getDrawable(context, R.drawable.rounded_bottom_corners) + + (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = + TOP_MARGIN_DP.dpToPx(context.resources.displayMetrics) + + return itemView + } + } +} 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 87fea0fe9c..200472cd9b 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 @@ -28,7 +28,7 @@ class RecentTabViewHolder( private val icons: BrowserIcons = view.context.components.core.icons ) : ViewHolder(view) { - fun bindTab(tab: TabSessionState) { + fun bindTab(tab: TabSessionState): View { // A page may take a while to retrieve a title, so let's show the url until we get one. recent_tab_title.text = if (tab.content.title.isNotEmpty()) { tab.content.title @@ -46,6 +46,8 @@ class RecentTabViewHolder( itemView.setOnClickListener { interactor.onRecentTabClicked(tab.id) } + + return itemView } companion object { 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 6ac4a94929..5b121265d8 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 @@ -20,6 +20,7 @@ import mozilla.components.ui.widgets.WidgetSiteItemView import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.home.OnboardingState +import org.mozilla.fenix.home.recenttabs.view.RecentTabViewDecorator import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.NoCollectionsMessageViewHolder @@ -41,6 +42,7 @@ import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingWh import org.mozilla.fenix.home.recenttabs.view.RecentTabViewHolder import org.mozilla.fenix.home.recenttabs.view.RecentTabsHeaderViewHolder import org.mozilla.fenix.home.recentbookmarks.view.RecentBookmarksViewHolder +import org.mozilla.fenix.home.recenttabs.view.RecentTabsItemPosition import org.mozilla.fenix.home.tips.ButtonTipViewHolder import mozilla.components.feature.tab.collections.Tab as ComponentTab @@ -141,8 +143,12 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) { object OnboardingWhatsNew : AdapterItem(OnboardingWhatsNewViewHolder.LAYOUT_ID) object RecentTabsHeader : AdapterItem(RecentTabsHeaderViewHolder.LAYOUT_ID) - data class RecentTabItem(val tab: TabSessionState) : AdapterItem(RecentTabViewHolder.LAYOUT_ID) { - override fun sameAs(other: AdapterItem) = other is RecentTabItem && tab.id == other.tab.id + data class RecentTabItem( + val tab: TabSessionState, + val position: RecentTabsItemPosition + ) : AdapterItem(RecentTabViewHolder.LAYOUT_ID) { + override fun sameAs(other: AdapterItem) = other is RecentTabItem && tab.id == other.tab.id && + position == other.position override fun contentsSameAs(other: AdapterItem): Boolean { val otherItem = other as RecentTabItem @@ -316,7 +322,10 @@ class SessionControlAdapter( (item as AdapterItem.OnboardingAutomaticSignIn).state.withAccount ) is RecentTabViewHolder -> { - holder.bindTab((item as AdapterItem.RecentTabItem).tab) + val (tab, tabPosition) = item as AdapterItem.RecentTabItem + holder.bindTab(tab).apply { + RecentTabViewDecorator.forPosition(tabPosition).invoke(this) + } } is RecentBookmarksViewHolder -> { holder.bind( diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt index 53697a2d09..d6c10c9b91 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.home.sessioncontrol import android.view.View +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -20,6 +21,7 @@ import org.mozilla.fenix.home.HomeFragmentState import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.Mode import org.mozilla.fenix.home.OnboardingState +import org.mozilla.fenix.home.recenttabs.view.RecentTabsItemPosition // This method got a little complex with the addition of the tab tray feature flag // When we remove the tabs from the home screen this will get much simpler again. @@ -65,13 +67,42 @@ private fun normalModeAdapterItems( return items } -private fun showRecentTabs( +/** + * Constructs the list of items to be shown in the recent tabs section. + * + * This section's structure is: + * - section header + * - one or more normal tabs + * - zero or one media tab (if there is a tab opened on which media started playing. + * This may be a duplicate of one of the normal tabs shown above). + */ +@VisibleForTesting +internal fun showRecentTabs( recentTabs: List, items: MutableList ) { items.add(AdapterItem.RecentTabsHeader) - recentTabs.forEach { - items.add(AdapterItem.RecentTabItem(it)) + + recentTabs.forEachIndexed { index, recentTab -> + // If this is the first tab to be shown but more will follow. + if (index == 0 && recentTabs.size > 1) { + items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.TOP)) + } + + // if this is the only tab to be shown. + else if (index == 0 && recentTabs.size == 1) { + items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.SINGLE)) + } + + // If there are items above and below. + else if (index < recentTabs.size - 1) { + items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.MIDDLE)) + } + + // If this is the last recent tab to be shown. + else if (index < recentTabs.size) { + items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.BOTTOM)) + } } } diff --git a/app/src/main/res/layout/recent_tabs_list_row.xml b/app/src/main/res/layout/recent_tabs_list_row.xml index a9cb925c02..2fcb8747d8 100644 --- a/app/src/main/res/layout/recent_tabs_list_row.xml +++ b/app/src/main/res/layout/recent_tabs_list_row.xml @@ -8,7 +8,6 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="48dp" - android:background="@drawable/home_list_row_background" android:clipToPadding="false" android:elevation="@dimen/home_item_elevation" android:foreground="?android:attr/selectableItemBackground"> diff --git a/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt b/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt index 34e4aaed36..168b684b62 100644 --- a/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt +++ b/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt @@ -4,61 +4,146 @@ package org.mozilla.fenix.ext +import io.mockk.mockk import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.createTab import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.fenix.helpers.FenixRobolectricTestRunner -@RunWith(FenixRobolectricTestRunner::class) class BrowserStateTest { @Test - fun `WHEN there is a selected tab THEN asRecentTabs returns the selected tab as a list`() { - val tab = createTab( - url = "https://www.mozilla.org", - id = "1" + fun `GIVEN a tab which had media playing WHEN inProgressMediaTab is called THEN return that tab`() { + val inProgressMediaTab = createTab(url = "mediaUrl", id = "2", lastMediaAccess = 123) + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), inProgressMediaTab, mockk(relaxed = true)) ) - val tabs = listOf(tab) - val state = BrowserState( - tabs = tabs, - selectedTabId = tab.id - ) - val recentTabs = state.asRecentTabs() - assertEquals(tabs, recentTabs) + assertEquals(inProgressMediaTab, browserState.inProgressMediaTab) } @Test - fun `WHEN there is no selected tab THEN asRecentTabs returns an empty list`() { - val state = BrowserState( - tabs = listOf( - createTab( - url = "https://www.mozilla.org", - id = "1" - ) - ) + fun `GIVEN no tab which had media playing exists WHEN inProgressMediaTab is called THEN return null`() { + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)) ) - val recentTabs = state.asRecentTabs() - assertEquals(0, recentTabs.size) + assertNull(browserState.inProgressMediaTab) } @Test - fun `WHEN the selected tab is private THEN asRecentTabs returns an empty list`() { - val tab = createTab( - url = "https://www.mozilla.org", - id = "1", - private = true + fun `GIVEN the selected tab is a normal tab and no media tab WHEN asRecentTabs is called THEN return a list of that tab`() { + val selectedTab = createTab(url = "url", id = "3") + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), selectedTab, mockk(relaxed = true)), + selectedTabId = selectedTab.id ) - val tabs = listOf(tab) - val state = BrowserState( - tabs = tabs, - selectedTabId = tab.id - ) - val recentTabs = state.asRecentTabs() - assertEquals(0, recentTabs.size) + val result = browserState.asRecentTabs() + + assertEquals(1, result.size) + assertEquals(selectedTab, result[0]) + } + + @Test + fun `GIVEN the selected tab is a private tab and no media tab WHEN asRecentTabs is called THEN return a list of the last accessed normal tab`() { + val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true) + val lastAccessedNormalTab = createTab(url = "url2", id = "2", lastAccess = 2) + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), lastAccessedNormalTab, selectedPrivateTab), + selectedTabId = selectedPrivateTab.id + ) + + val result = browserState.asRecentTabs() + + assertEquals(1, result.size) + assertEquals(lastAccessedNormalTab, result[0]) + } + + @Test + fun `GIVEN the selected tab is a normal tab and another media tab exists WHEN asRecentTabs is called THEN return a list of these tabs`() { + val selectedTab = createTab(url = "url", id = "3") + val mediaTab = createTab("mediaUrl", id = "23", lastMediaAccess = 123) + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), selectedTab, mediaTab), + selectedTabId = selectedTab.id + ) + + val result = browserState.asRecentTabs() + + assertEquals(2, result.size) + assertEquals(selectedTab, result[0]) + assertEquals(mediaTab, result[1]) + } + + @Test + fun `GIVEN the selected tab is a private tab and another media tab exists WHEN asRecentTabs is called THEN return a list of the last normal tab and the media tab`() { + val lastAccessedNormalTab = createTab(url = "url2", id = "2", lastAccess = 2) + val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true) + val mediaTab = createTab("mediaUrl", id = "12", lastAccess = 0, lastMediaAccess = 123) + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), lastAccessedNormalTab, selectedPrivateTab, mediaTab), + selectedTabId = selectedPrivateTab.id + ) + + val result = browserState.asRecentTabs() + + assertEquals(2, result.size) + assertEquals(lastAccessedNormalTab, result[0]) + assertEquals(mediaTab, result[1]) + } + + @Test + fun `GIVEN the selected tab is a private tab and the media tab is the last accessed normal tab WHEN asRecentTabs is called THEN a list of the media tab`() { + val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true) + val normalTab = createTab(url = "url2", id = "2", lastAccess = 2) + val mediaTab = createTab("mediaUrl", id = "12", lastAccess = 20, lastMediaAccess = 123) + val browserState = BrowserState( + tabs = listOf(mockk(relaxed = true), normalTab, selectedPrivateTab, mediaTab), + selectedTabId = selectedPrivateTab.id + ) + + val result = browserState.asRecentTabs() + + assertEquals(1, result.size) + assertEquals(mediaTab, result[0]) + } + + @Test + fun `GIVEN only private tabs and a private one selected WHEN lastOpenedNormalTab is called THEN return null`() { + val selectedPrivateTab = createTab(url = "url", id = "1", private = true) + val otherPrivateTab = createTab(url = "url2", id = "2", private = true) + val browserState = BrowserState( + tabs = listOf(selectedPrivateTab, otherPrivateTab), + selectedTabId = "1" + ) + + assertNull(browserState.lastOpenedNormalTab) + } + + @Test + fun `GIVEN normal tabs exists but a private one is selected WHEN lastOpenedNormalTab is called THEN return the last accessed normal tab`() { + val selectedPrivateTab = createTab(url = "url", id = "1", private = true) + val normalTab1 = createTab(url = "url2", id = "2", private = false, lastAccess = 2) + val normalTab2 = createTab(url = "url3", id = "3", private = false, lastAccess = 3) + val browserState = BrowserState( + tabs = listOf(selectedPrivateTab, normalTab1, normalTab2), + selectedTabId = "3" + ) + + assertEquals(normalTab2, browserState.lastOpenedNormalTab) + } + + @Test + fun `GIVEN a normal tab is selected WHEN lastOpenedNormalTab is called THEN return the selected normal tab`() { + val normalTab1 = createTab(url = "url1", id = "1", private = false) + val normalTab2 = createTab(url = "url2", id = "2", private = false) + val browserState = BrowserState( + tabs = listOf(normalTab1, normalTab2), + selectedTabId = "1" + ) + + assertEquals(normalTab1, browserState.lastOpenedNormalTab) } } diff --git a/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt b/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt index c077aeced4..ca2b3815f0 100644 --- a/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt @@ -9,10 +9,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineDispatcher import mozilla.components.browser.state.action.ContentAction.UpdateIconAction import mozilla.components.browser.state.action.ContentAction.UpdateTitleAction +import mozilla.components.browser.state.action.MediaSessionAction 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.concept.engine.mediasession.MediaSession +import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.middleware.CaptureActionsMiddleware @@ -49,7 +52,7 @@ class RecentTabsListFeatureTest { } @Test - fun `GIVEN no selected or last active tab WHEN the feature starts THEN dispatch an empty list`() { + fun `GIVEN no selected, last active or in progress media tab WHEN the feature starts THEN dispatch an empty list`() { val browserStore = BrowserStore() val homeStore = HomeFragmentStore() val feature = RecentTabsListFeature( @@ -111,6 +114,46 @@ class RecentTabsListFeatureTest { assertEquals(1, homeStore.state.recentTabs.size) } + @Test + fun `GIVEN a valid inProgressMediaTabId and another selected tab exists WHEN the feature starts THEN dispatch both as as a recent tabs list`() { + val mediaTab = createTab("https://mozilla.com", id = "42", lastMediaAccess = 123) + val selectedTab = createTab("https://mozilla.com", id = "43") + val browserStore = BrowserStore(BrowserState( + tabs = listOf(mediaTab, selectedTab), + selectedTabId = "43" + )) + val feature = RecentTabsListFeature( + browserStore = browserStore, + homeStore = homeStore + ) + + feature.start() + homeStore.waitUntilIdle() + + assertEquals(2, homeStore.state.recentTabs.size) + assertEquals(selectedTab, homeStore.state.recentTabs[0]) + assertEquals(mediaTab, homeStore.state.recentTabs[1]) + } + + @Test + fun `GIVEN a valid inProgressMediaTabId exists and that is the selected tab WHEN the feature starts THEN dispatch just one tab as the recent tabs list`() { + val selectedMediaTab = createTab("https://mozilla.com", id = "42", lastMediaAccess = 123) + val browserStore = BrowserStore(BrowserState( + tabs = listOf(selectedMediaTab), + selectedTabId = "42" + )) + val feature = RecentTabsListFeature( + browserStore = browserStore, + homeStore = homeStore + ) + + feature.start() + homeStore.waitUntilIdle() + + assertEquals(1, homeStore.state.recentTabs.size) + assertEquals(selectedMediaTab, homeStore.state.recentTabs[0]) + } + @Test fun `WHEN the browser state has an updated select tab THEN dispatch the new recent tab list`() { val tab1 = createTab( @@ -148,6 +191,40 @@ class RecentTabsListFeatureTest { assertEquals(tab2, homeStore.state.recentTabs[0]) } + @Test + fun `WHEN the browser state has an in progress media tab THEN dispatch the new recent tab list`() { + val initialMediaTab = createTab(url = "https://mozilla.com", id = "1", lastMediaAccess = 123) + val newMediaTab = createTab(url = "http://mozilla.org", id = "2", lastMediaAccess = 100) + val browserStore = BrowserStore( + initialState = BrowserState( + tabs = listOf(initialMediaTab, newMediaTab), + selectedTabId = "1" + ), + middleware = listOf(LastMediaAccessMiddleware()) + ) + val feature = RecentTabsListFeature( + browserStore = browserStore, + homeStore = homeStore + ) + + feature.start() + homeStore.waitUntilIdle() + assertEquals(1, homeStore.state.recentTabs.size) + assertEquals(initialMediaTab, homeStore.state.recentTabs[0]) + + browserStore.dispatch( + MediaSessionAction.UpdateMediaPlaybackStateAction("2", MediaSession.PlaybackState.PLAYING) + ).joinBlocking() + homeStore.waitUntilIdle() + assertEquals(2, homeStore.state.recentTabs.size) + assertEquals(initialMediaTab, homeStore.state.recentTabs[0]) + // UpdateMediaPlaybackStateAction would set the current timestamp as the new value for lastMediaAccess + val updatedLastMediaAccess = homeStore.state.recentTabs[1].lastMediaAccess + assertTrue("expected lastMediaAccess ($updatedLastMediaAccess) > 100", updatedLastMediaAccess > 100) + // Check that the media tab is updated ignoring just the lastMediaAccess property. + assertEquals(newMediaTab, homeStore.state.recentTabs[1].copy(lastMediaAccess = 100)) + } + @Test fun `WHEN the browser state selects a private tab THEN dispatch an empty list`() { val selectedNormalTab = createTab( @@ -242,4 +319,25 @@ class RecentTabsListFeatureTest { assertNotNull(tab.content.icon) } } + + @Test + fun `GIVEN inProgressMediaTab already set WHEN the media tab is closed THEN remove it from recent tabs`() { + val initialMediaTab = createTab(url = "https://mozilla.com", id = "1") + val selectedTab = createTab(url = "https://mozilla.com/firefox", id = "2") + val browserStore = BrowserStore( + initialState = BrowserState(listOf(initialMediaTab, selectedTab), selectedTabId = "2"), + middleware = listOf(LastMediaAccessMiddleware()) + ) + val feature = RecentTabsListFeature( + browserStore = browserStore, + homeStore = homeStore + ) + + feature.start() + browserStore.dispatch(TabListAction.RemoveTabsAction(listOf("1"))).joinBlocking() + homeStore.waitUntilIdle() + + assertEquals(1, homeStore.state.recentTabs.size) + assertEquals(selectedTab, homeStore.state.recentTabs[0]) + } } diff --git a/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt b/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt new file mode 100644 index 0000000000..d4f7251524 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt @@ -0,0 +1,142 @@ +/* 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.recenttabs.view + +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.ktx.android.util.dpToPx +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.R + +class RecentTabViewDecoratorTest { + @Test + fun `WHEN forPosition is called with RecentTabsItemPosition#SINGLE THEN return SingleTabDecoration`() { + val result = RecentTabViewDecorator.forPosition(RecentTabsItemPosition.SINGLE) + + assertTrue(result is RecentTabViewDecorator.SingleTabDecoration) + } + + @Test + fun `WHEN forPosition is called with RecentTabsItemPosition#TOP THEN return TopTabDecoration`() { + val result = RecentTabViewDecorator.forPosition(RecentTabsItemPosition.TOP) + + assertTrue(result is RecentTabViewDecorator.TopTabDecoration) + } + + @Test + fun `WHEN forPosition is called with RecentTabsItemPosition#MIDDLE THEN return MiddleTabDecoration`() { + val result = RecentTabViewDecorator.forPosition(RecentTabsItemPosition.MIDDLE) + + assertTrue(result is RecentTabViewDecorator.MiddleTabDecoration) + } + + @Test + fun `WHEN forPosition is called with RecentTabsItemPosition#BOTTOM THEN return SingleTabDecoration`() { + val result = RecentTabViewDecorator.forPosition(RecentTabsItemPosition.BOTTOM) + + assertTrue(result is RecentTabViewDecorator.BottomTabDecoration) + } + + @Test + fun `WHEN SingleTabDecoration is invoked for a View THEN set the appropriate background`() { + val view: View = mockk(relaxed = true) + val drawable: Drawable = mockk() + val drawableResCaptor = slot() + + try { + mockkStatic(AppCompatResources::class) + every { AppCompatResources.getDrawable(any(), capture(drawableResCaptor)) } returns drawable + + RecentTabViewDecorator.SingleTabDecoration(view) + + verify { view.background = drawable } + assertEquals(R.drawable.home_list_row_background, drawableResCaptor.captured) + } finally { + unmockkStatic(AppCompatResources::class) + } + } + + @Test + fun `WHEN TopTabDecoration is invoked for a View THEN set the appropriate background`() { + val view: View = mockk(relaxed = true) + val drawable: Drawable = mockk() + val drawableResCaptor = slot() + + try { + mockkStatic(AppCompatResources::class) + every { AppCompatResources.getDrawable(any(), capture(drawableResCaptor)) } returns drawable + + RecentTabViewDecorator.TopTabDecoration(view) + + verify { view.background = drawable } + assertEquals(R.drawable.rounded_top_corners, drawableResCaptor.captured) + } finally { + unmockkStatic(AppCompatResources::class) + } + } + + @Test + fun `WHEN MiddleTabDecoration is invoked for a View THEN set the appropriate background and layout params`() { + val colorAttrCaptor = slot() + val viewLayoutParams: ViewGroup.MarginLayoutParams = mockk(relaxed = true) + + try { + mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt") + mockkStatic("mozilla.components.support.ktx.android.content.ContextKt") + val view: View = mockk(relaxed = true) { + every { layoutParams } returns viewLayoutParams + every { context.getColorFromAttr(capture(colorAttrCaptor)) } returns 42 + every { context.resources.displayMetrics } returns mockk(relaxed = true) + } + every { any().dpToPx(any()) } returns 43 + + RecentTabViewDecorator.MiddleTabDecoration(view) + + verify { view.setBackgroundColor(42) } + assertEquals(R.attr.above, colorAttrCaptor.captured) + assertEquals(43, viewLayoutParams.topMargin) + } finally { + unmockkStatic("mozilla.components.support.ktx.android.content.ContextKt") + unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt") + } + } + + @Test + fun `WHEN BottomTabDecoration is invoked for a View THEN set the appropriate background and layout params`() { + val viewLayoutParams: ViewGroup.MarginLayoutParams = mockk(relaxed = true) + val drawable: Drawable = mockk() + val drawableResCaptor = slot() + + try { + mockkStatic(AppCompatResources::class) + every { AppCompatResources.getDrawable(any(), capture(drawableResCaptor)) } returns drawable + mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt") + val view: View = mockk(relaxed = true) { + every { layoutParams } returns viewLayoutParams + every { context.resources.displayMetrics } returns mockk(relaxed = true) + } + every { any().dpToPx(any()) } returns 43 + + RecentTabViewDecorator.BottomTabDecoration(view) + + verify { view.background = drawable } + assertEquals(R.drawable.rounded_bottom_corners, drawableResCaptor.captured) + assertEquals(43, viewLayoutParams.topMargin) + } finally { + unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt") + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt new file mode 100644 index 0000000000..cd92b83a20 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt @@ -0,0 +1,83 @@ +/* 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.sessioncontrol + +import io.mockk.mockk +import mozilla.components.browser.state.state.TabSessionState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.home.recenttabs.view.RecentTabsItemPosition + +class SessionControlViewTest { + @Test + fun `GIVEN two recent tabs WHEN showRecentTabs is called THEN add the header, and two recent items to be shown`() { + val recentTab: TabSessionState = mockk() + val mediaTab: TabSessionState = mockk() + val items = mutableListOf() + + showRecentTabs(listOf(recentTab, mediaTab), items) + + assertEquals(3, items.size) + assertTrue(items[0] is AdapterItem.RecentTabsHeader) + assertEquals(recentTab, (items[1] as AdapterItem.RecentTabItem).tab) + assertEquals(mediaTab, (items[2] as AdapterItem.RecentTabItem).tab) + } + + @Test + fun `GIVEN one recent tab WHEN showRecentTabs is called THEN add the header and the recent tab to items shown`() { + val recentTab: TabSessionState = mockk() + val items = mutableListOf() + + showRecentTabs(listOf(recentTab), items) + + assertEquals(2, items.size) + assertTrue(items[0] is AdapterItem.RecentTabsHeader) + assertEquals(recentTab, (items[1] as AdapterItem.RecentTabItem).tab) + } + + @Test + fun `GIVEN only one recent tab and no media tab WHEN showRecentTabs is called THEN add the recent item as a single one to be shown`() { + val recentTab: TabSessionState = mockk() + val items = mutableListOf() + + showRecentTabs(listOf(recentTab), items) + + assertEquals(recentTab, (items[1] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.SINGLE, (items[1] as AdapterItem.RecentTabItem).position) + } + + @Test + fun `GIVEN two recent tabs WHEN showRecentTabs is called THEN add one item as top and one as bottom to be shown`() { + val recentTab: TabSessionState = mockk() + val mediaTab: TabSessionState = mockk() + val items = mutableListOf() + + showRecentTabs(listOf(recentTab, mediaTab), items) + + assertEquals(recentTab, (items[1] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.TOP, (items[1] as AdapterItem.RecentTabItem).position) + assertEquals(mediaTab, (items[2] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.BOTTOM, (items[2] as AdapterItem.RecentTabItem).position) + } + + @Test + fun `GIVEN three recent tabs WHEN showRecentTabs is called THEN add one recent item as top, one as middle and one as bottom to be shown`() { + val recentTab1: TabSessionState = mockk() + val recentTab2: TabSessionState = mockk() + val mediaTab: TabSessionState = mockk() + val items = mutableListOf() + + showRecentTabs(listOf(recentTab1, recentTab2, mediaTab), items) + + assertEquals(recentTab1, (items[1] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.TOP, (items[1] as AdapterItem.RecentTabItem).position) + assertEquals(recentTab2, (items[2] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.MIDDLE, (items[2] as AdapterItem.RecentTabItem).position) + assertEquals(mediaTab, (items[3] as AdapterItem.RecentTabItem).tab) + assertSame(RecentTabsItemPosition.BOTTOM, (items[3] as AdapterItem.RecentTabItem).position) + } +}