diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index 58f4aa76ace4..de4a63296bc9 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -67,5 +67,5 @@ object FeatureFlags { /** * Identifies and separates the tabs list with a group containing search term tabs. */ - val tabGroupFeature = Config.channel.isDebug + 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 dedcf83c8f9a..527619c02ca9 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt @@ -10,9 +10,6 @@ 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.R import org.mozilla.fenix.sync.SyncedTabsAdapter @@ -20,8 +17,6 @@ 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 @@ -31,50 +26,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( InactiveTabsAdapter(context, browserInteractor, INACTIVE_TABS_FEATURE_NAME), - TabGroupAdapter(context, browserInteractor, store, TAB_GROUP_FEATURE_NAME), - TitleHeaderAdapter(context.getString(R.string.tab_tray_header_title)), - BrowserTabsAdapter(context, browserInteractor, store, TABS_TRAY_FEATURE_NAME) + TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME), + TitleHeaderAdapter(browserStore, R.string.tab_tray_header_title), + 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.") 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 5172e229c045..c29d6602c8d9 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 @@ -17,7 +17,7 @@ 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.isNormalTabWithSearchTerm +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm import org.mozilla.fenix.tabstray.ext.tabGroupAdapter import java.util.concurrent.TimeUnit @@ -68,7 +68,7 @@ class NormalBrowserTrayList @JvmOverloads constructor( if (!FeatureFlags.tabGroupFeature) { return@filter false } - it.isNormalTabWithSearchTerm(maxActiveTime) + it.isNormalTabActiveWithSearchTerm(maxActiveTime) } val tabsAdapter = concatAdapter.tabGroupAdapter @@ -123,9 +123,9 @@ class NormalBrowserTrayList @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - tabsFeature.start() - searchTermFeature.start() inactiveFeature.start() + searchTermFeature.start() + tabsFeature.start() touchHelper.attachToRecyclerView(this) } 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 cfb785a6ee14..83d6338eb603 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 index d5bcd20ecb71..6801053d2155 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt @@ -10,17 +10,22 @@ 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.Tab as TabsTrayTab 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.support.base.observer.Observable as ComponentObservable +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. @@ -30,38 +35,74 @@ import mozilla.components.support.base.observer.Observable as ComponentObservabl * @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: ComponentObservable = ObserverRegistry() -) : ListAdapter(DiffCallback), - TabsTray, - ComponentObservable by delegate { + 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) + TabGroupViewHolder(view, HORIZONTAL, browserTrayInteractor, store, selectionHolder) } else -> { - TabGroupViewHolder(view, VERTICAL) + TabGroupViewHolder(view, VERTICAL, browserTrayInteractor, store, selectionHolder) } } } override fun onBindViewHolder(holder: TabGroupViewHolder, position: Int) { val group = getItem(position) - holder.bind(group, browserTrayInteractor, store, this) + 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() } - override fun getItemViewType(position: Int): Int { - return TabGroupViewHolder.LAYOUT_ID + /** + * 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() } @@ -82,37 +123,21 @@ class TabGroupAdapter( submitList(grouping) } - 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 - ) - - override fun isTabSelected(tabs: Tabs, position: Int): Boolean = - tabs.selectedIndex == position + /** + * 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): Boolean { - return oldItem.title == newItem.title - } - - override fun areContentsTheSame(oldItem: Group, newItem: Group): Boolean { - return oldItem == newItem - } + 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 index 58127b77581e..a6fb3f28d25a 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt @@ -6,9 +6,13 @@ 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 @@ -19,6 +23,7 @@ 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 @@ -26,7 +31,7 @@ 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 browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @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. @@ -36,31 +41,33 @@ class TabGroupListAdapter( 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 { - val view = when { + 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()) - view + BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) } else -> { - LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false) + val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false) + BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) } } - - return BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, null, view, featureName) } override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) { val tab = getItem(position) - holder.bind(tab, false, TabsTrayStyling(), delegate) + 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 -> { @@ -79,13 +86,69 @@ class TabGroupListAdapter( } } - private object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Tab, newItem: Tab): Boolean { - return oldItem.id == newItem.id + /** + * 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 areContentsTheSame(oldItem: Tab, newItem: Tab): Boolean { - return oldItem == newItem + 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 index 7566c57022c4..d38aa5e3c6d8 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt @@ -5,10 +5,13 @@ 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 @@ -20,32 +23,61 @@ import org.mozilla.fenix.tabstray.browser.TabGroupListAdapter * * @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 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, - interactor: BrowserTrayInteractor, - store: TabsTrayStore, observable: Observable ) { - // bind title + val selectedTabId = itemView.context.components.core.store.state.selectedTabId + val selectedIndex = group.tabs.indexOfFirst { it.id == selectedTabId } + binding.tabGroupTitle.text = group.title - // bind recyclerview for search term adapter binding.tabGroupList.apply { - val groupListAdapter = TabGroupListAdapter( - itemView.context, interactor, store, observable, TrayPagerAdapter.TAB_GROUP_FEATURE_NAME - ) 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 index 75f87bb5bd8e..fc8f99d1b6c3 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt @@ -7,8 +7,11 @@ package org.mozilla.fenix.tabstray.browser import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import org.mozilla.fenix.FeatureFlags +import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R import org.mozilla.fenix.databinding.TabTrayTitleHeaderItemBinding @@ -17,41 +20,60 @@ import org.mozilla.fenix.databinding.TabTrayTitleHeaderItemBinding * * @param title [String] used for the title */ -class TitleHeaderAdapter(val title: String) : RecyclerView.Adapter() { +class TitleHeaderAdapter( + private val browserStore: BrowserStore, + @StringRes val title: Int +) : ListAdapter(DiffCallback) { + + object 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, title) } - override fun getItemViewType(position: Int): Int { - return HeaderViewHolder.LAYOUT_ID + override fun getItemViewType(position: Int) = HeaderViewHolder.LAYOUT_ID + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.start() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.stop() } - override fun getItemCount(): Int { - return if (FeatureFlags.tabGroupFeature) { - 1 + /* Do nothing */ + override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) = Unit + + private fun handleListChanges(showHeader: Boolean) { + val header = if (showHeader) { + listOf(Header) } else { - 0 + emptyList() } - } - override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { - /* Do nothing */ + submitList(header) } class HeaderViewHolder( itemView: View, - val title: String + @StringRes val title: Int ) : RecyclerView.ViewHolder(itemView) { private val binding = TabTrayTitleHeaderItemBinding.bind(itemView) fun bind() { - binding.tabTrayHeaderTitle.text = title + binding.tabTrayHeaderTitle.text = itemView.context.getString(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 000000000000..ece921dde1aa --- /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/RecyclerViewAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt new file mode 100644 index 000000000000..3034da27e88b --- /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 000000000000..b24fcfdcaf26 --- /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 55f2873b6bfb..ab0e3069d32a 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,21 +13,21 @@ private fun TabSessionState.isActive(maxActiveTime: Long): Boolean { } /** - * Returns true if a [TabSessionState] has a search term. + * Returns true if the [TabSessionState] has a search term. */ private fun TabSessionState.hasSearchTerm(): Boolean { return content.searchTerms.isNotEmpty() || !historyMetadata?.searchTerm.isNullOrBlank() } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * 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] and + * 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 { @@ -35,15 +35,22 @@ internal fun TabSessionState.isNormalTabActiveWithoutSearchTerm(maxActiveTime: L } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * Returns true if the [TabSessionState] have a search term. */ -internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean { - return !isActive(maxActiveTime) && !content.private +internal fun TabSessionState.isNormalTabActiveWithSearchTerm(maxActiveTime: Long): Boolean { + return isNormalTabActive(maxActiveTime) && hasSearchTerm() } /** - * Returns true if a [TabSessionState] have a search term. + * Returns true if the [TabSessionState] has a search term but may or may not be active. */ -internal fun TabSessionState.isNormalTabWithSearchTerm(maxActiveTime: Long): Boolean { - return isNormalTabActive(maxActiveTime) && hasSearchTerm() +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 071c2a335af2..50cbbb65bc6e 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 6f3f3085479b..d3dc99f6ec66 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,19 +4,31 @@ 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 /** @@ -24,16 +36,10 @@ import org.mozilla.fenix.tabstray.ext.tabGroupAdapter */ 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. @@ -42,23 +48,111 @@ 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) override fun bind( adapter: RecyclerView.Adapter + ) { + 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 - browserAdapter.selectionHolder = this + + val selectedTab = browserStore.state.selectedNormalTab ?: return + + // 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 - val manager = GridLayoutManager(containerView.context, numberOfColumns).apply { + return GridLayoutManager(context, numberOfColumns).apply { spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return if (position >= inactiveTabAdapter.itemCount + tabGroupAdapter.itemCount + @@ -71,8 +165,6 @@ class NormalBrowserPageViewHolder( } } } - - 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 7e83b5903a9a..78810e95278f 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 index fe82d9ed9baf..a9321ae9903d 100644 --- a/app/src/main/res/layout/tab_group_item.xml +++ b/app/src/main/res/layout/tab_group_item.xml @@ -4,23 +4,36 @@ + + + app:layout_constraintBottom_toBottomOf="@id/group_icon" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@+id/group_icon" + app:layout_constraintTop_toTopOf="@id/group_icon" + tools:text="Cats" /> - - - - - \ No newline at end of file + android:gravity="start" + android:maxLines="1" + android:text="@string/tab_tray_header_title" + android:textAppearance="@style/Header16TextStyle" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> \ No newline at end of file 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 3f6598f246ea..f857540558db 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/TitleHeaderBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBindingTest.kt new file mode 100644 index 000000000000..f2c33eaf3ac8 --- /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 cfdb579fa243..32590fa74ffa 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 9bbbacbf07cf..8f4e5a9e661a 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)