From fc18fd25201816177db5077264185d54ef2458b6 Mon Sep 17 00:00:00 2001 From: Roger Yang Date: Thu, 16 Sep 2021 23:26:34 -0400 Subject: [PATCH] For #21236: Separate tabs with the same search term into a different section (#21177) * For #21236: Separate tabs with the same search term into a different section in tabs tray * Issue #21236: Scroll to selected tab + various tab fixes for groupings * Issue #21236: Fix failing test Co-authored-by: Jonathan Almeida --- .../java/org/mozilla/fenix/FeatureFlags.kt | 5 + .../fenix/tabstray/TrayPagerAdapter.kt | 50 +++--- .../browser/BrowserTabGridViewHolder.kt | 69 -------- .../browser/BrowserTabListViewHolder.kt | 53 ------ .../tabstray/browser/BrowserTabViewHolder.kt | 120 ++++++++++++++ .../tabstray/browser/BrowserTabsAdapter.kt | 20 ++- .../browser/InactiveTabsInteractor.kt | 2 +- .../tabstray/browser/NormalBrowserTrayList.kt | 34 +++- .../browser/SelectedItemAdapterBinding.kt | 10 +- .../fenix/tabstray/browser/TabGroupAdapter.kt | 143 ++++++++++++++++ .../tabstray/browser/TabGroupListAdapter.kt | 154 ++++++++++++++++++ .../tabstray/browser/TabGroupViewHolder.kt | 84 ++++++++++ .../tabstray/browser/TitleHeaderAdapter.kt | 73 +++++++++ .../tabstray/browser/TitleHeaderBinding.kt | 36 ++++ .../fenix/tabstray/ext/ConcatAdapter.kt | 14 ++ .../org/mozilla/fenix/tabstray/ext/Context.kt | 2 +- .../fenix/tabstray/ext/RecyclerViewAdapter.kt | 20 +++ .../fenix/tabstray/ext/TabSelectors.kt | 57 +++++++ .../fenix/tabstray/ext/TabSessionState.kt | 33 +++- .../AbstractBrowserPageViewHolder.kt | 33 ++-- .../NormalBrowserPageViewHolder.kt | 137 ++++++++++++++-- .../PrivateBrowserPageViewHolder.kt | 25 ++- app/src/main/res/layout/tab_group_item.xml | 47 ++++++ .../res/layout/tab_tray_title_header_item.xml | 24 +++ app/src/main/res/values/strings.xml | 2 + .../fenix/tabstray/TabsTrayFragmentTest.kt | 2 +- .../browser/BrowserTabsAdapterTest.kt | 2 +- .../browser/SelectedItemAdapterBindingTest.kt | 12 -- .../browser/TitleHeaderBindingTest.kt | 84 ++++++++++ .../tabstray/ext/TabSessionStateKtTest.kt | 23 +++ .../AbstractBrowserPageViewHolderTest.kt | 10 +- 31 files changed, 1159 insertions(+), 221 deletions(-) delete mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt create mode 100644 app/src/main/res/layout/tab_group_item.xml create mode 100644 app/src/main/res/layout/tab_tray_title_header_item.xml create mode 100644 app/src/test/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBindingTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index 1e33746583..de4a63296b 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -63,4 +63,9 @@ object FeatureFlags { * Enables customizing the home screen */ val customizeHome = Config.channel.isNightlyOrDebug + + /** + * Identifies and separates the tabs list with a group containing search term tabs. + */ + val tabGroupFeature = Config.channel.isNightlyOrDebug } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt index 3ee2f2325a..472a0a5967 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt @@ -10,16 +10,13 @@ import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.RecyclerView -import mozilla.components.browser.state.selector.normalTabs -import mozilla.components.browser.state.selector.privateTabs -import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.sync.SyncedTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter -import org.mozilla.fenix.tabstray.browser.maxActiveTime -import org.mozilla.fenix.tabstray.ext.isNormalTabActive +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter import org.mozilla.fenix.tabstray.syncedtabs.TabClickDelegate import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder import org.mozilla.fenix.tabstray.viewholders.NormalBrowserPageViewHolder @@ -28,48 +25,62 @@ import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder class TrayPagerAdapter( @VisibleForTesting internal val context: Context, - @VisibleForTesting internal val store: TabsTrayStore, + @VisibleForTesting internal val tabsTrayStore: TabsTrayStore, @VisibleForTesting internal val browserInteractor: BrowserTrayInteractor, @VisibleForTesting internal val navInteractor: NavigationInteractor, @VisibleForTesting internal val interactor: TabsTrayInteractor, @VisibleForTesting internal val browserStore: BrowserStore ) : RecyclerView.Adapter() { + /** + * ⚠️ N.B: Scrolling to the selected tab depends on the order of these adapters. If you change + * the ordering or add/remove an adapter, please update [NormalBrowserPageViewHolder.scrollToTab] and + * the layout manager. + */ private val normalAdapter by lazy { ConcatAdapter( - BrowserTabsAdapter(context, browserInteractor, store, TABS_TRAY_FEATURE_NAME), - InactiveTabsAdapter(context, browserInteractor, INACTIVE_TABS_FEATURE_NAME) + InactiveTabsAdapter(context, browserInteractor, INACTIVE_TABS_FEATURE_NAME), + TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME), + TitleHeaderAdapter(browserStore), + BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME) ) } - private val privateAdapter by lazy { BrowserTabsAdapter(context, browserInteractor, store, TABS_TRAY_FEATURE_NAME) } - private val syncedTabsAdapter by lazy { SyncedTabsAdapter(TabClickDelegate(navInteractor)) } + private val privateAdapter by lazy { + BrowserTabsAdapter( + context, + browserInteractor, + tabsTrayStore, + TABS_TRAY_FEATURE_NAME + ) + } + private val syncedTabsAdapter by lazy { + SyncedTabsAdapter(TabClickDelegate(navInteractor)) + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractPageViewHolder { val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - val selectedTab = browserStore.state.selectedTab - return when (viewType) { NormalBrowserPageViewHolder.LAYOUT_ID -> { NormalBrowserPageViewHolder( itemView, - store, - interactor, - browserStore.state.normalTabs.filter { it.isNormalTabActive(maxActiveTime) }.indexOf(selectedTab) + tabsTrayStore, + browserStore, + interactor ) } PrivateBrowserPageViewHolder.LAYOUT_ID -> { PrivateBrowserPageViewHolder( itemView, - store, - interactor, - browserStore.state.privateTabs.indexOf(selectedTab) + tabsTrayStore, + browserStore, + interactor ) } SyncedTabsPageViewHolder.LAYOUT_ID -> { SyncedTabsPageViewHolder( itemView, - store + tabsTrayStore ) } else -> throw IllegalStateException("Unknown viewType.") @@ -102,6 +113,7 @@ class TrayPagerAdapter( // Telemetry keys for identifying from which app features the a was opened / closed. const val TABS_TRAY_FEATURE_NAME = "Tabs tray" + const val TAB_GROUP_FEATURE_NAME = "Tab group" const val INACTIVE_TABS_FEATURE_NAME = "Inactive tabs" val POSITION_NORMAL_TABS = Page.NormalTabs.ordinal diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt deleted file mode 100644 index 5ca700e27c..0000000000 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.tabstray.browser - -import android.view.View -import androidx.appcompat.content.res.AppCompatResources -import androidx.appcompat.widget.AppCompatImageButton -import mozilla.components.browser.tabstray.TabsTrayStyling -import mozilla.components.concept.base.images.ImageLoader -import mozilla.components.concept.tabstray.Tab -import mozilla.components.concept.tabstray.TabsTray -import mozilla.components.support.base.observer.Observable -import org.mozilla.fenix.R -import org.mozilla.fenix.databinding.TabTrayGridItemBinding -import org.mozilla.fenix.ext.increaseTapArea -import kotlin.math.max -import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.tabstray.TabsTrayStore - -/** - * A RecyclerView ViewHolder implementation for "tab" items with grid layout. - * - * @param imageLoader [ImageLoader] used to load tab thumbnails. - * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. - * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. - * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. - * @param itemView [View] that displays a "tab". - * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. - */ -class BrowserTabGridViewHolder( - imageLoader: ImageLoader, - override val browserTrayInteractor: BrowserTrayInteractor, - store: TabsTrayStore, - selectionHolder: SelectionHolder? = null, - itemView: View, - featureName: String -) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { - - private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) - - override val thumbnailSize: Int - get() = max( - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width) - ) - - override fun updateSelectedTabIndicator(showAsSelected: Boolean) { - val binding = TabTrayGridItemBinding.bind(itemView) - binding.tabTrayGridItem.background = if (showAsSelected) { - AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) - } else { - null - } - return - } - - override fun bind( - tab: Tab, - isSelected: Boolean, - styling: TabsTrayStyling, - observable: Observable - ) { - super.bind(tab, isSelected, styling, observable) - - closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt deleted file mode 100644 index 9e217c29c2..0000000000 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.tabstray.browser - -import android.view.View -import androidx.core.content.ContextCompat -import mozilla.components.concept.base.images.ImageLoader -import mozilla.components.concept.tabstray.Tab -import org.mozilla.fenix.R -import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.tabstray.TabsTrayStore -import kotlin.math.max - -/** - * A RecyclerView ViewHolder implementation for "tab" items with list layout. - * - * @param imageLoader [ImageLoader] used to load tab thumbnails. - * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. - * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. - * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. - * @param itemView [View] that displays a "tab". - * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. -*/ -class BrowserTabListViewHolder( - imageLoader: ImageLoader, - override val browserTrayInteractor: BrowserTrayInteractor, - store: TabsTrayStore, - selectionHolder: SelectionHolder? = null, - itemView: View, - featureName: String -) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { - override val thumbnailSize: Int - get() = max( - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width) - ) - - override fun updateSelectedTabIndicator(showAsSelected: Boolean) { - val color = if (showAsSelected) { - R.color.tab_tray_item_selected_background_normal_theme - } else { - R.color.tab_tray_item_background_normal_theme - } - itemView.setBackgroundColor( - ContextCompat.getColor( - itemView.context, - color - ) - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt new file mode 100644 index 0000000000..654dcf65b2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.AppCompatImageButton +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.tabstray.TabsTrayStyling +import mozilla.components.concept.base.images.ImageLoader +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayGridItemBinding +import org.mozilla.fenix.ext.increaseTapArea +import kotlin.math.max +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore + +sealed class BrowserTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + /** + * A RecyclerView ViewHolder implementation for "tab" items with grid layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class GridViewHolder( + imageLoader: ImageLoader, + override val browserTrayInteractor: BrowserTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + + private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) + + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width) + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val binding = TabTrayGridItemBinding.bind(itemView) + binding.tabTrayGridItem.background = if (showAsSelected) { + AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) + } else { + null + } + return + } + + override fun bind( + tab: Tab, + isSelected: Boolean, + styling: TabsTrayStyling, + observable: Observable + ) { + super.bind(tab, isSelected, styling, observable) + + closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_grid_item + } + } + + /** + * A RecyclerView ViewHolder implementation for "tab" items with list layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class ListViewHolder( + imageLoader: ImageLoader, + override val browserTrayInteractor: BrowserTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width) + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val color = if (showAsSelected) { + R.color.tab_tray_item_selected_background_normal_theme + } else { + R.color.tab_tray_item_background_normal_theme + } + itemView.setBackgroundColor( + ContextCompat.getColor( + itemView.context, + color + ) + ) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_item + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt index 9e89a2123f..a4e47a5a70 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt @@ -16,7 +16,6 @@ import mozilla.components.concept.tabstray.Tab import mozilla.components.concept.tabstray.TabsTray import mozilla.components.support.base.observer.Observable import mozilla.components.support.base.observer.ObserverRegistry -import org.mozilla.fenix.R import org.mozilla.fenix.components.Components import org.mozilla.fenix.databinding.TabTrayGridItemBinding import org.mozilla.fenix.databinding.TabTrayItemBinding @@ -45,8 +44,8 @@ class BrowserTabsAdapter( * The layout types for the tabs. */ enum class ViewType(val layoutRes: Int) { - LIST(R.layout.tab_tray_item), - GRID(R.layout.tab_tray_grid_item) + LIST(BrowserTabViewHolder.ListViewHolder.LAYOUT_ID), + GRID(BrowserTabViewHolder.GridViewHolder.LAYOUT_ID) } /** @@ -58,10 +57,13 @@ class BrowserTabsAdapter( private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage) override fun getItemViewType(position: Int): Int { - return if (context.components.settings.gridTabView) { - ViewType.GRID.layoutRes - } else { - ViewType.LIST.layoutRes + return when { + context.components.settings.gridTabView -> { + ViewType.GRID.layoutRes + } + else -> { + ViewType.LIST.layoutRes + } } } @@ -70,9 +72,9 @@ class BrowserTabsAdapter( return when (viewType) { ViewType.GRID.layoutRes -> - BrowserTabGridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) else -> - BrowserTabListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt index 08d01debb2..0dfb886ea6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt @@ -22,5 +22,5 @@ class DefaultInactiveTabsInteractor( * TODO This should be replaced with the AppStore. */ object InactiveTabsState { - var isExpanded = true + var isExpanded = false } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt index 975a3707fd..c29d6602c8 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt @@ -15,7 +15,10 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter import org.mozilla.fenix.tabstray.ext.isNormalTabActive +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithoutSearchTerm import org.mozilla.fenix.tabstray.ext.isNormalTabInactive +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm +import org.mozilla.fenix.tabstray.ext.tabGroupAdapter import java.util.concurrent.TimeUnit /** @@ -48,12 +51,37 @@ class NormalBrowserTrayList @JvmOverloads constructor( if (!FeatureFlags.inactiveTabs) { return@TabsFeature !state.content.private } - state.isNormalTabActive(maxActiveTime) + + if (!FeatureFlags.tabGroupFeature) { + state.isNormalTabActive(maxActiveTime) + } else { + state.isNormalTabActiveWithoutSearchTerm(maxActiveTime) + } }, {} ) } + private val searchTermFeature by lazy { + val store = context.components.core.store + val tabFilter: (TabSessionState) -> Boolean = filter@{ + if (!FeatureFlags.tabGroupFeature) { + return@filter false + } + it.isNormalTabActiveWithSearchTerm(maxActiveTime) + } + val tabsAdapter = concatAdapter.tabGroupAdapter + + TabsFeature( + tabsAdapter, + store, + selectTabUseCase, + removeTabUseCase, + tabFilter, + {} + ) + } + /** * NB: The setup for this feature is a bit complicated without a better dependency injection * solution to scope it down to just this view. @@ -95,8 +123,9 @@ class NormalBrowserTrayList @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - tabsFeature.start() inactiveFeature.start() + searchTermFeature.start() + tabsFeature.start() touchHelper.attachToRecyclerView(this) } @@ -105,6 +134,7 @@ class NormalBrowserTrayList @JvmOverloads constructor( super.onDetachedFromWindow() tabsFeature.stop() + searchTermFeature.stop() inactiveFeature.stop() touchHelper.attachToRecyclerView(null) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt index cfb785a6ee..83d6338eb6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt @@ -4,10 +4,10 @@ package org.mozilla.fenix.tabstray.browser +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM @@ -23,19 +23,21 @@ import org.mozilla.fenix.tabstray.TabsTrayStore @OptIn(ExperimentalCoroutinesApi::class) class SelectedItemAdapterBinding( store: TabsTrayStore, - val adapter: BrowserTabsAdapter + val adapter: RecyclerView.Adapter ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { flow.map { it.mode } - // ignore initial mode update; the adapter is already in an updated state. - .drop(1) .ifChanged() .collect { mode -> notifyAdapter(mode) } } + /** + * N.B: This method should be made more performant to find the position of the multi-selected tab that has + * changed in the adapter, and then [RecyclerView.Adapter.notifyItemChanged]. + */ private fun notifyAdapter(mode: Mode) = with(adapter) { if (mode == Mode.Normal) { notifyItemRangeChanged(0, itemCount, PAYLOAD_HIGHLIGHT_SELECTED_ITEM) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt new file mode 100644 index 0000000000..6801053d21 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import TabGroupViewHolder +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.HORIZONTAL +import androidx.recyclerview.widget.RecyclerView.VERTICAL +import mozilla.components.concept.tabstray.Tabs +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.components.Components +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter.Group +import kotlin.math.max +import mozilla.components.concept.tabstray.Tab as TabsTrayTab +import mozilla.components.support.base.observer.Observable + +typealias TrayObservable = Observable + +/** + * The [ListAdapter] for displaying the list of search term tabs. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. + */ +@Suppress("TooManyFunctions") +class TabGroupAdapter( + private val context: Context, + private val browserTrayInteractor: BrowserTrayInteractor, + private val store: TabsTrayStore, + private val featureName: String, + delegate: TrayObservable = ObserverRegistry() +) : ListAdapter(DiffCallback), TabsTray, TrayObservable by delegate { + + data class Group( + /** + * A title for the tab group. + */ + val title: String, + + /** + * The list of tabs belonging to this tab group. + */ + val tabs: List, + + /** + * The last time tabs in this group was accessed. + */ + val lastAccess: Long + ) + + /** + * Tracks the selected tabs in multi-select mode. + */ + var selectionHolder: SelectionHolder? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabGroupViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + + return when { + context.components.settings.gridTabView -> { + TabGroupViewHolder(view, HORIZONTAL, browserTrayInteractor, store, selectionHolder) + } + else -> { + TabGroupViewHolder(view, VERTICAL, browserTrayInteractor, store, selectionHolder) + } + } + } + + override fun onBindViewHolder(holder: TabGroupViewHolder, position: Int) { + val group = getItem(position) + holder.bind(group, this) + } + + override fun getItemViewType(position: Int) = TabGroupViewHolder.LAYOUT_ID + + /** + * Notify the nested [RecyclerView] when this view has been attached. + */ + override fun onViewAttachedToWindow(holder: TabGroupViewHolder) { + holder.rebind() + } + + /** + * Notify the nested [RecyclerView] when this view has been detached. + */ + override fun onViewDetachedFromWindow(holder: TabGroupViewHolder) { + holder.unbind() + } + + /** + * Creates a grouping of data classes for how groupings will be structured. + */ + override fun updateTabs(tabs: Tabs) { + val data = tabs.list.groupBy { it.searchTerm.lowercase() } + + val grouping = data.map { mapEntry -> + val searchTerm = mapEntry.key.replaceFirstChar(Char::uppercase) + val groupTabs = mapEntry.value + val groupMax = groupTabs.fold(0L) { acc, tab -> + max(tab.lastAccess, acc) + } + + Group( + title = searchTerm, + tabs = groupTabs, + lastAccess = groupMax + ) + }.sortedBy { it.lastAccess } + + submitList(grouping) + } + + /** + * Not implemented; handled by nested [RecyclerView]. + */ + override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false + override fun onTabsChanged(position: Int, count: Int) = Unit + override fun onTabsInserted(position: Int, count: Int) = Unit + override fun onTabsMoved(fromPosition: Int, toPosition: Int) = Unit + override fun onTabsRemoved(position: Int, count: Int) = Unit + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Group, newItem: Group) = oldItem.title == newItem.title + override fun areContentsTheSame(oldItem: Group, newItem: Group) = oldItem == newItem + } +} + +internal fun Group.containsTabId(tabId: String): Boolean { + return tabs.firstOrNull { it.id == tabId } != null +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt new file mode 100644 index 0000000000..a6fb3f28d2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM +import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM +import mozilla.components.browser.tabstray.TabsTrayStyling +import mozilla.components.browser.thumbnails.loader.ThumbnailLoader +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayGridItemBinding +import org.mozilla.fenix.databinding.TabTrayItemBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.dpToPx +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP + +/** + * The [ListAdapter] for displaying the list of tabs that have the same search term. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ +class TabGroupListAdapter( + private val context: Context, + private val interactor: BrowserTrayInteractor, + private val store: TabsTrayStore, + private val delegate: Observable, + private val selectionHolder: SelectionHolder?, + private val featureName: String, +) : ListAdapter(DiffCallback) { + + private val selectedItemAdapterBinding = SelectedItemAdapterBinding(store, this) + private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractBrowserTabViewHolder { + return when { + context.components.settings.gridTabView -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_grid_item, parent, false) + view.layoutParams.width = view.dpToPx(MIN_COLUMN_WIDTH_DP.toFloat()) + BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + } + else -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false) + BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + } + } + } + + override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) { + val tab = getItem(position) + val selectedTabId = context.components.core.store.state.selectedTabId + holder.bind(tab, tab.id == selectedTabId, TabsTrayStyling(), delegate) + holder.tab?.let { holderTab -> + when { + context.components.settings.gridTabView -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + gridBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.close(holderTab, featureName) + } + } + else -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + listBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.close(holderTab, featureName) + } + } + } + } + } + + /** + * Over-ridden [onBindViewHolder] that uses the payloads to notify the selected tab how to + * display itself. + * + * N.B: this is a modified version of [BrowserTabsAdapter.onBindViewHolder]. + */ + override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List) { + val tabs = currentList + val selectedTabId = context.components.core.store.state.selectedTabId + val selectedIndex = tabs.indexOfFirst { it.id == selectedTabId } + + if (tabs.isEmpty()) return + + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + + if (position == selectedIndex) { + if (payloads.contains(PAYLOAD_HIGHLIGHT_SELECTED_ITEM)) { + holder.updateSelectedTabIndicator(true) + } else if (payloads.contains(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)) { + holder.updateSelectedTabIndicator(false) + } + } + + selectionHolder?.let { + var selectedMaskView: View? = null + when (getItemViewType(position)) { + BrowserTabsAdapter.ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + } + BrowserTabsAdapter.ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + } + } + holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(holder.tab)) + } + } + + override fun getItemViewType(position: Int): Int { + return when { + context.components.settings.gridTabView -> { + BrowserTabsAdapter.ViewType.GRID.layoutRes + } + else -> { + BrowserTabsAdapter.ViewType.LIST.layoutRes + } + } + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + selectedItemAdapterBinding.start() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + selectedItemAdapterBinding.stop() + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Tab, newItem: Tab) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Tab, newItem: Tab) = oldItem == newItem + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt new file mode 100644 index 0000000000..d38aa5e3c6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt @@ -0,0 +1,84 @@ +/* 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/. */ + +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabGroupItemBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.TrayPagerAdapter +import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter +import org.mozilla.fenix.tabstray.browser.TabGroupListAdapter + +/** + * A RecyclerView ViewHolder implementation for tab group items. + * + * @param itemView [View] that displays a "tab". + * @param orientation [Int] orientation of the items. Horizontal for grid layout, vertical for list layout + * @param interactor the [BrowserTrayInteractor] for tab interactions. + * @param store the [TabsTrayStore] instance. + * @param selectionHolder the store that holds the currently selected tabs. + */ +class TabGroupViewHolder( + itemView: View, + val orientation: Int, + val interactor: BrowserTrayInteractor, + val store: TabsTrayStore, + val selectionHolder: SelectionHolder? = null +) : RecyclerView.ViewHolder(itemView) { + private val binding = TabGroupItemBinding.bind(itemView) + + lateinit var groupListAdapter: TabGroupListAdapter + + fun bind( + group: TabGroupAdapter.Group, + observable: Observable + ) { + val selectedTabId = itemView.context.components.core.store.state.selectedTabId + val selectedIndex = group.tabs.indexOfFirst { it.id == selectedTabId } + + binding.tabGroupTitle.text = group.title + binding.tabGroupList.apply { + layoutManager = LinearLayoutManager(itemView.context, orientation, false) + groupListAdapter = TabGroupListAdapter( + context = itemView.context, + interactor = interactor, + store = store, + delegate = observable, + selectionHolder = selectionHolder, + featureName = TrayPagerAdapter.TAB_GROUP_FEATURE_NAME + ) + + adapter = groupListAdapter + + groupListAdapter.submitList(group.tabs) + scrollToPosition(selectedIndex) + } + } + + /** + * Notify the nested [RecyclerView] that it has been detached. + */ + fun unbind() { + groupListAdapter.onDetachedFromRecyclerView(binding.tabGroupList) + } + + /** + * Notify the nested [RecyclerView] that it has been attached. This is so our observers know when to start again. + */ + fun rebind() { + groupListAdapter.onAttachedToRecyclerView(binding.tabGroupList) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_group_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt new file mode 100644 index 0000000000..445e7b7932 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.store.BrowserStore +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayTitleHeaderItemBinding + +/** + * A [RecyclerView.Adapter] for tab header. + */ +class TitleHeaderAdapter( + browserStore: BrowserStore +) : ListAdapter(DiffCallback) { + + class Header + + private val normalTabsHeaderBinding = TitleHeaderBinding(browserStore, ::handleListChanges) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return HeaderViewHolder(view) + } + + override fun getItemViewType(position: Int) = HeaderViewHolder.LAYOUT_ID + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.start() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.stop() + } + + /* Do nothing */ + override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) = Unit + + private fun handleListChanges(showHeader: Boolean) { + val header = if (showHeader) { + listOf(Header()) + } else { + emptyList() + } + + submitList(header) + } + + class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val binding = TabTrayTitleHeaderItemBinding.bind(itemView) + + fun bind() { + binding.tabTrayHeaderTitle.text = + itemView.context.getString(R.string.tab_tray_header_title) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_title_header_item + } + } + + private object DiffCallback : DiffUtil.ItemCallback
() { + override fun areItemsTheSame(oldItem: Header, newItem: Header) = true + override fun areContentsTheSame(oldItem: Header, newItem: Header) = true + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt new file mode 100644 index 0000000000..ece921dde1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +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.ifChanged +import org.mozilla.fenix.tabstray.ext.normalTrayTabs + +/** + * A binding class to notify an observer to show a title if there is at least one tab available. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TitleHeaderBinding( + store: BrowserStore, + private val showHeader: (Boolean) -> Unit +) : AbstractBinding(store) { + override suspend fun onState(flow: Flow) { + flow.map { it.normalTrayTabs } + .ifChanged { it.size } + .collect { + if (it.isEmpty()) { + showHeader(false) + } else { + showHeader(true) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt index 5b052f0951..ff9cf3d77e 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt @@ -6,7 +6,9 @@ package org.mozilla.fenix.tabstray.ext import androidx.recyclerview.widget.ConcatAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter +import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter /** * A convenience binding for retrieving the [BrowserTabsAdapter] from the [ConcatAdapter]. @@ -19,3 +21,15 @@ internal val ConcatAdapter.browserAdapter */ internal val ConcatAdapter.inactiveTabsAdapter get() = adapters.find { it is InactiveTabsAdapter } as InactiveTabsAdapter + +/** + * A convenience binding for retrieving the [TabGroupAdapter] from the [ConcatAdapter]. + */ +internal val ConcatAdapter.tabGroupAdapter + get() = adapters.find { it is TabGroupAdapter } as TabGroupAdapter + +/** + * A convenience binding for retrieving the [TitleHeaderAdapter] from the [ConcatAdapter]. + */ +internal val ConcatAdapter.titleHeaderAdapter + get() = adapters.find { it is TitleHeaderAdapter } as TitleHeaderAdapter diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt index 663968364b..92ad59cbfb 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt @@ -8,7 +8,7 @@ import android.content.Context import org.mozilla.fenix.ext.components import org.mozilla.fenix.tabstray.browser.AutoCloseInterval -private const val MIN_COLUMN_WIDTH_DP = 180 +const val MIN_COLUMN_WIDTH_DP = 180 /** * Returns the number of grid columns we can fit on the screen in the tabs tray. diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt new file mode 100644 index 0000000000..3034da27e8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ext + +import androidx.recyclerview.widget.RecyclerView + +/** + * Observes the adapter and invokes the callback [block] only when data is first inserted to the adapter. + */ +fun RecyclerView.Adapter.observeFirstInsert(block: () -> Unit) { + val observer = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + block.invoke() + unregisterAdapterDataObserver(this) + } + } + registerAdapterDataObserver(observer) +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt new file mode 100644 index 0000000000..b24fcfdcaf --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ext + +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.tabstray.browser.maxActiveTime + +/** + * The currently selected tab if there's one that is private. + * + * NB: Upstream to Selectors.kt. + */ +val BrowserState.selectedPrivateTab: TabSessionState? + get() = selectedTabId?.let { id -> findPrivateTab(id) } + +/** + * Finds and returns the private tab with the given id. Returns null if no + * matching tab could be found. + * + * @param tabId The ID of the tab to search for. + * @return The [TabSessionState] with the provided [tabId] or null if it could not be found. + * + * NB: Upstream to Selectors.kt. + */ +fun BrowserState.findPrivateTab(tabId: String): TabSessionState? { + return privateTabs.firstOrNull { it.id == tabId } +} + +/** + * The list of inactive tabs in the tabs tray filtered based on [maxActiveTime]. + */ +val BrowserState.inactiveTabs: List + get() = normalTabs.filter { it.isNormalTabInactive(maxActiveTime) } + +/** + * The list of normal tabs in the tabs tray filtered appropriately based on feature flags. + */ +val BrowserState.normalTrayTabs: List + get() { + return normalTabs.run { + if (FeatureFlags.tabGroupFeature && FeatureFlags.inactiveTabs) { + filter { it.isNormalTabActiveWithoutSearchTerm(maxActiveTime) } + } else if (FeatureFlags.inactiveTabs) { + filter { it.isNormalTabActive(maxActiveTime) } + } else if (FeatureFlags.tabGroupFeature) { + filter { it.isNormalTabWithSearchTerm() } + } else { + this + } + } + } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt index 495539c434..ab0e3069d3 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt @@ -13,14 +13,43 @@ private fun TabSessionState.isActive(maxActiveTime: Long): Boolean { } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * Returns true if the [TabSessionState] has a search term. + */ +private fun TabSessionState.hasSearchTerm(): Boolean { + return content.searchTerms.isNotEmpty() || !historyMetadata?.searchTerm.isNullOrBlank() +} + +/** + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime]. */ internal fun TabSessionState.isNormalTabActive(maxActiveTime: Long): Boolean { return isActive(maxActiveTime) && !content.private } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime] and + * does not have a search term + */ +internal fun TabSessionState.isNormalTabActiveWithoutSearchTerm(maxActiveTime: Long): Boolean { + return isNormalTabActive(maxActiveTime) && !hasSearchTerm() +} + +/** + * Returns true if the [TabSessionState] have a search term. + */ +internal fun TabSessionState.isNormalTabActiveWithSearchTerm(maxActiveTime: Long): Boolean { + return isNormalTabActive(maxActiveTime) && hasSearchTerm() +} + +/** + * Returns true if the [TabSessionState] has a search term but may or may not be active. + */ +internal fun TabSessionState.isNormalTabWithSearchTerm(): Boolean { + return hasSearchTerm() && !content.private +} + +/** + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime]. */ internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean { return !isActive(maxActiveTime) && !content.private diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt index 071c2a335a..50cbbb65bc 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt @@ -14,6 +14,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.browser.AbstractBrowserTrayList +import org.mozilla.fenix.tabstray.ext.observeFirstInsert /** * A shared view holder for browser tabs tray list. @@ -22,7 +23,6 @@ abstract class AbstractBrowserPageViewHolder( containerView: View, tabsTrayStore: TabsTrayStore, interactor: TabsTrayInteractor, - private val currentTabIndex: Int ) : AbstractPageViewHolder(containerView) { private val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item) @@ -35,17 +35,23 @@ abstract class AbstractBrowserPageViewHolder( emptyList.text = emptyStringText } + /** + * A way for an implementor of [AbstractBrowserPageViewHolder] to define their own scroll-to-tab behaviour. + */ + abstract fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) + @CallSuper protected fun bind( adapter: RecyclerView.Adapter, layoutManager: RecyclerView.LayoutManager ) { - adapter.registerAdapterDataObserver( - OneTimeAdapterObserver(adapter) { - trayList.scrollToPosition(currentTabIndex) - updateTrayVisibility(adapter.itemCount) - } - ) + adapter.observeFirstInsert { + updateTrayVisibility(adapter.itemCount) + } + scrollToTab(adapter, layoutManager) trayList.layoutManager = layoutManager trayList.adapter = adapter } @@ -60,16 +66,3 @@ abstract class AbstractBrowserPageViewHolder( } } } - -/** - * Observes the adapter and invokes the callback when data is first inserted. - */ -class OneTimeAdapterObserver( - private val adapter: RecyclerView.Adapter, - private val onAdapterReady: () -> Unit -) : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - onAdapterReady.invoke() - adapter.unregisterAdapterDataObserver(this) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt index a6c370cfb3..d3dc99f6ec 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt @@ -4,33 +4,42 @@ package org.mozilla.fenix.tabstray.viewholders +import android.content.Context import android.view.View import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.selector.selectedNormalTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.tabstray.Tab +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.browser.InactiveTabsState +import org.mozilla.fenix.tabstray.browser.containsTabId +import org.mozilla.fenix.tabstray.browser.maxActiveTime import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns +import org.mozilla.fenix.tabstray.ext.inactiveTabs +import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter +import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter +import org.mozilla.fenix.tabstray.ext.isNormalTabInactive +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm +import org.mozilla.fenix.tabstray.ext.normalTrayTabs +import org.mozilla.fenix.tabstray.ext.observeFirstInsert +import org.mozilla.fenix.tabstray.ext.tabGroupAdapter /** * View holder for the normal tabs tray list. */ class NormalBrowserPageViewHolder( containerView: View, - private val store: TabsTrayStore, + private val tabsTrayStore: TabsTrayStore, + private val browserStore: BrowserStore, interactor: TabsTrayInteractor, - currentTabIndex: Int -) : AbstractBrowserPageViewHolder( - containerView, - store, - interactor, - currentTabIndex -), - SelectionHolder { +) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder { /** * Holds the list of selected tabs. @@ -39,7 +48,7 @@ class NormalBrowserPageViewHolder( * to select tabs. */ override val selectedItems: Set - get() = store.state.mode.selectedTabs + get() = tabsTrayStore.state.mode.selectedTabs override val emptyStringText: String get() = itemView.resources.getString(R.string.no_open_tabs_description) @@ -47,23 +56,115 @@ class NormalBrowserPageViewHolder( override fun bind( adapter: RecyclerView.Adapter ) { - val browserAdapter = (adapter as ConcatAdapter).browserAdapter + val concatAdapter = adapter as ConcatAdapter + val browserAdapter = concatAdapter.browserAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + val manager = setupLayoutManager(containerView.context, concatAdapter) + browserAdapter.selectionHolder = this + tabGroupAdapter.selectionHolder = this + + super.bind(adapter, manager) + } + + /** + * Add giant explanation why this is complicated. + */ + override fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) { + val concatAdapter = adapter as ConcatAdapter + val headerAdapter = concatAdapter.titleHeaderAdapter + val browserAdapter = concatAdapter.browserAdapter + val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + + val selectedTab = browserStore.state.selectedNormalTab ?: return - val number = containerView.context.defaultBrowserLayoutColumns - val manager = GridLayoutManager(containerView.context, number).apply { + // Update tabs into the inactive adapter. + if (FeatureFlags.inactiveTabs && selectedTab.isNormalTabInactive(maxActiveTime)) { + val inactiveTabsList = browserStore.state.inactiveTabs + // We want to expand the inactive section first before we want to fire our scroll observer. + InactiveTabsState.isExpanded = true + inactiveTabAdapter.observeFirstInsert { + inactiveTabsList.forEachIndexed { tabIndex, item -> + if (item.id == selectedTab.id) { + // Inactive Tabs are first + inactive header item. + val indexToScrollTo = tabIndex + 1 + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + // Updates tabs into the search term group adapter. + if (FeatureFlags.tabGroupFeature && selectedTab.isNormalTabActiveWithSearchTerm(maxActiveTime)) { + tabGroupAdapter.observeFirstInsert { + // With a grouping, we need to use the list of the adapter that is already grouped + // together for the UI, so we know the final index of the grouping to scroll to. + // + // N.B: Why are we using currentList here and no where else? `currentList` is an API on top of + // `ListAdapter` which is updated when the [ListAdapter.submitList] is invoked. For our BrowserAdapter + // as an example, the updates are coming from [TabsFeature] which internally uses the internal + // [DiffUtil.calculateDiff] directly to submit a changed list which evades the `ListAdapter` from being + // notified of updates, so it therefore returns an empty list. + tabGroupAdapter.currentList.forEachIndexed { groupIndex, group -> + if (group.containsTabId(selectedTab.id)) { + + // Index is based on tabs above (inactive) with our calculated index. + val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + // Updates tabs into the normal browser tabs adapter. + browserAdapter.observeFirstInsert { + val activeTabsList = browserStore.state.normalTrayTabs + activeTabsList.forEachIndexed { tabIndex, trayTab -> + if (trayTab.id == selectedTab.id) { + + // Index is based on tabs above (inactive + groups + header) with our calculated index. + val indexToScrollTo = inactiveTabAdapter.itemCount + + tabGroupAdapter.itemCount + + headerAdapter.itemCount + tabIndex + + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + private fun setupLayoutManager( + context: Context, + concatAdapter: ConcatAdapter + ): GridLayoutManager { + val headerAdapter = concatAdapter.titleHeaderAdapter + val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + + val numberOfColumns = containerView.context.defaultBrowserLayoutColumns + return GridLayoutManager(context, numberOfColumns).apply { spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { - return if (position >= browserAdapter.itemCount) { - number - } else { + return if (position >= inactiveTabAdapter.itemCount + tabGroupAdapter.itemCount + + headerAdapter.itemCount + ) { 1 + } else { + numberOfColumns } } } } - - super.bind(adapter, manager) } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt index 7e83b5903a..78810e9527 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt @@ -7,29 +7,44 @@ package org.mozilla.fenix.tabstray.viewholders import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns +import org.mozilla.fenix.tabstray.ext.observeFirstInsert +import org.mozilla.fenix.tabstray.ext.selectedPrivateTab /** * View holder for the private tabs tray list. */ class PrivateBrowserPageViewHolder( containerView: View, - store: TabsTrayStore, - interactor: TabsTrayInteractor, - currentTabIndex: Int + tabsTrayStore: TabsTrayStore, + private val browserStore: BrowserStore, + interactor: TabsTrayInteractor ) : AbstractBrowserPageViewHolder( containerView, - store, + tabsTrayStore, interactor, - currentTabIndex ) { override val emptyStringText: String get() = itemView.resources.getString(R.string.no_private_tabs_description) + override fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) { + adapter.observeFirstInsert { + val selectedTab = browserStore.state.selectedPrivateTab ?: return@observeFirstInsert + val scrollIndex = browserStore.state.privateTabs.indexOf(selectedTab) + + layoutManager.scrollToPosition(scrollIndex) + } + } + override fun bind( adapter: RecyclerView.Adapter ) { diff --git a/app/src/main/res/layout/tab_group_item.xml b/app/src/main/res/layout/tab_group_item.xml new file mode 100644 index 0000000000..a9321ae990 --- /dev/null +++ b/app/src/main/res/layout/tab_group_item.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/tab_tray_title_header_item.xml b/app/src/main/res/layout/tab_tray_title_header_item.xml new file mode 100644 index 0000000000..04a548630a --- /dev/null +++ b/app/src/main/res/layout/tab_tray_title_header_item.xml @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78e8647674..52e489b8fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,6 +740,8 @@ %1$s (Private Mode) Save + + Other diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt index 3f6598f246..f857540558 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt @@ -217,7 +217,7 @@ class TabsTrayFragmentTest { val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter) assertSame(context, adapter.context) - assertSame(store, adapter.store) + assertSame(store, adapter.tabsTrayStore) assertSame(trayInteractor, adapter.interactor) assertSame(browserInteractor, adapter.browserInteractor) assertSame(navigationInteractor, adapter.navInteractor) diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt index 3217584294..736270c81e 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt @@ -56,7 +56,7 @@ class BrowserTabsAdapterTest { val adapter = BrowserTabsAdapter(context, interactor, store, "Test") val binding = TabTrayItemBinding.inflate(LayoutInflater.from(testContext)) val holder = spyk( - BrowserTabListViewHolder( + BrowserTabViewHolder.ListViewHolder( imageLoader = mockk(), browserTrayInteractor = interactor, store = store, diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt index 5120b854d0..c4db0c65b1 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt @@ -32,18 +32,6 @@ class SelectedItemAdapterBindingTest { every { adapter.itemCount }.answers { 1 } } - @Test - fun `WHEN observing on start THEN ignore the initial state update`() { - val store = TabsTrayStore() - val binding = SelectedItemAdapterBinding(store, adapter) - - binding.start() - - verify(exactly = 0) { - adapter.notifyItemRangeChanged(any(), any(), any()) - } - } - @Test fun `WHEN mode changes THEN notify the adapter`() { val store = TabsTrayStore() diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBindingTest.kt new file mode 100644 index 0000000000..f2c33eaf3a --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBindingTest.kt @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.storage.HistoryMetadataKey +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class TitleHeaderBindingTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `WHEN normal tabs are added to the list THEN return true`() { + var result = false + val store = BrowserStore() + val binding = TitleHeaderBinding(store) { result = it } + + store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org"))) + + binding.start() + + store.waitUntilIdle() + + assertTrue(result) + } + + @Test + fun `WHEN grouped tabs are added to the list THEN return false`() { + var result = false + val store = BrowserStore() + val binding = TitleHeaderBinding(store) { result = it } + + store.dispatch( + TabListAction.AddTabAction( + createTab( + url = "https://mozilla.org", + historyMetadata = HistoryMetadataKey( + url = "https://getpocket.com", + searchTerm = "Mozilla" + ) + ) + ) + ) + + binding.start() + + store.waitUntilIdle() + + assertFalse(result) + } + + @Test + fun `WHEN normal tabs are all removed THEN return false`() { + var result = false + val store = BrowserStore( + initialState = BrowserState( + tabs = listOf(createTab("https://getpocket.com", id = "123")) + ) + ) + val binding = TitleHeaderBinding(store) { result = it } + + store.dispatch(TabListAction.RemoveTabAction("123")) + + binding.start() + + store.waitUntilIdle() + + assertFalse(result) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt index cfdb579fa2..32590fa74f 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.tabstray.ext import mozilla.components.browser.state.state.createTab +import mozilla.components.concept.storage.HistoryMetadataKey import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -103,4 +104,26 @@ class TabSessionStateKtTest { ) assertFalse(tab.isNormalTabActive(maxTime)) } + + @Test + fun `WHEN tab has a search term or metadata THEN return true `() { + val tab = createTab( + url = "https://mozilla.org", + createdAt = System.currentTimeMillis(), + historyMetadata = HistoryMetadataKey("https://getpockjet.com", "cats") + ) + val tab2 = createTab( + url = "https://mozilla.org", + createdAt = System.currentTimeMillis(), + searchTerms = "dogs" + ) + val tab3 = createTab( + url = "https://mozilla.org", + createdAt = inactiveTimestamp, + searchTerms = "dogs" + ) + assertTrue(tab.isNormalTabActiveWithSearchTerm(maxTime)) + assertTrue(tab2.isNormalTabActiveWithSearchTerm(maxTime)) + assertFalse(tab3.isNormalTabActiveWithSearchTerm(maxTime)) + } } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt index 9bbbacbf07..8f4e5a9e66 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt @@ -9,6 +9,7 @@ import android.view.View.GONE import android.view.View.VISIBLE import android.widget.TextView import io.mockk.mockk +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.tabstray.Tabs import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertTrue @@ -25,16 +26,17 @@ import org.mozilla.fenix.tabstray.browser.createTab @RunWith(FenixRobolectricTestRunner::class) class AbstractBrowserPageViewHolderTest { - val store: TabsTrayStore = TabsTrayStore() + val tabsTrayStore: TabsTrayStore = TabsTrayStore() + val browserStore = BrowserStore() val interactor = mockk(relaxed = true) val browserTrayInteractor = mockk(relaxed = true) - val adapter = BrowserTabsAdapter(testContext, browserTrayInteractor, store, "Test") + val adapter = BrowserTabsAdapter(testContext, browserTrayInteractor, tabsTrayStore, "Test") @Test fun `WHEN tabs inserted THEN show tray`() { val itemView = LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null) - val viewHolder = PrivateBrowserPageViewHolder(itemView, store, interactor, 5) + val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor) val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item) val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view) @@ -58,7 +60,7 @@ class AbstractBrowserPageViewHolderTest { fun `WHEN no tabs THEN show empty view`() { val itemView = LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null) - val viewHolder = PrivateBrowserPageViewHolder(itemView, store, interactor, 5) + val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor) val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item) val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view)