[fenix] Closes https://github.com/mozilla-mobile/fenix/issues/22550: Refactor search term tab grouping logic to rely on middleware

pull/600/head
Roger Yang 2 years ago committed by mergify[bot]
parent d68adfa81b
commit 5cc5b50595

@ -82,6 +82,7 @@ import org.mozilla.fenix.perf.StrictModeManager
import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.advanced.getSelectedLocale
import org.mozilla.fenix.tabstray.SearchTermTabGroupMiddleware
import org.mozilla.fenix.telemetry.TelemetryMiddleware
import org.mozilla.fenix.utils.getUndoDelay
import org.mozilla.geckoview.GeckoRuntime
@ -210,7 +211,8 @@ class Core(
PromptMiddleware(),
AdsTelemetryMiddleware(adsTelemetry),
LastMediaAccessMiddleware(),
HistoryMetadataMiddleware(historyMetadataService)
HistoryMetadataMiddleware(historyMetadataService),
SearchTermTabGroupMiddleware()
)
BrowserStore(

@ -4,18 +4,19 @@
package org.mozilla.fenix.ext
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.selectedNormalTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.feature.tabs.ext.hasMediaPlayed
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.tabstray.browser.TabGroup
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS_MIN_SIZE
import org.mozilla.fenix.tabstray.ext.isNormalTabInactive
import org.mozilla.fenix.utils.Settings
import java.util.concurrent.TimeUnit
import kotlin.math.max
/**
* The time until which a tab is considered in-active (in days).
@ -41,7 +42,7 @@ fun BrowserState.asRecentTabs(): List<RecentTab> {
} else {
listOf(selectedNormalTab)
.plus(normalTabs.sortedByDescending { it.lastAccess })
.minus(lastTabGroup?.tabs ?: emptyList())
.filterNot { lastTabGroup?.tabIds?.contains(it?.id) ?: false }
.firstOrNull()
}
@ -80,7 +81,8 @@ val BrowserState.inProgressMediaTab: TabSessionState?
* Result will be `null` if the currently open normal tabs are not part of a search group.
*/
val BrowserState.lastTabGroup: TabGroup?
get() = normalTabs.toSearchGroup().first.lastOrNull()
get() = tabPartitions[SEARCH_TERM_TAB_GROUPS]?.tabGroups
?.lastOrNull { it.tabIds.size >= SEARCH_TERM_TAB_GROUPS_MIN_SIZE }
/**
* Get the most recent search term group.
@ -88,55 +90,18 @@ val BrowserState.lastTabGroup: TabGroup?
val BrowserState.lastSearchGroup: RecentTab.SearchGroup?
get() {
val tabGroup = lastTabGroup ?: return null
val firstTab = tabGroup.tabs.firstOrNull() ?: return null
val firstTabId = tabGroup.tabIds.firstOrNull() ?: return null
val firstTab = findTab(firstTabId) ?: return null
return RecentTab.SearchGroup(
tabGroup.searchTerm,
firstTab.id,
tabGroup.id,
firstTabId,
firstTab.content.url,
firstTab.content.thumbnail,
tabGroup.tabs.count()
tabGroup.tabIds.size
)
}
/**
* Returns a pair containing a list of search term groups sorted by last access time, and "remainder" tabs that have
* search terms but should not be in groups (because the group is of size one).
*/
fun List<TabSessionState>.toSearchGroup(
groupSet: Set<String> = emptySet()
): Pair<List<TabGroup>, List<TabSessionState>> {
val data = filter {
it.isNormalTabActiveWithSearchTerm(maxActiveTime)
}.groupBy {
when {
it.content.searchTerms.isNotBlank() -> it.content.searchTerms
else -> it.historyMetadata?.searchTerm ?: ""
}.lowercase()
}
val groupings = 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)
}
TabGroup(
searchTerm = searchTerm,
tabs = groupTabs,
lastAccess = groupMax
)
}
val groups = groupings
.filter { it.tabs.size > 1 || groupSet.contains(it.searchTerm) }
.sortedBy { it.lastAccess }
val remainderTabs = (groupings - groups).flatMap { it.tabs }
return groups to remainderTabs
}
/**
* List of all inactive tabs based on [maxActiveTime].
* The user may have disabled the feature so for user interactions consider using the [actualInactiveTabs] method

@ -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
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.TabGroupAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.getGroupByName
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
const val SEARCH_TERM_TAB_GROUPS = "searchTermTabGroups"
const val SEARCH_TERM_TAB_GROUPS_MIN_SIZE = 2
/**
* This [Middleware] manages tab groups for search terms.
*/
class SearchTermTabGroupMiddleware : Middleware<BrowserState, BrowserAction> {
override fun invoke(
context: MiddlewareContext<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction
) {
next(action)
when (action) {
is HistoryMetadataAction.SetHistoryMetadataKeyAction -> {
action.historyMetadataKey.searchTerm?.let { searchTerm ->
context.dispatch(
TabGroupAction.AddTabAction(SEARCH_TERM_TAB_GROUPS, searchTerm, action.tabId)
)
}
}
is HistoryMetadataAction.DisbandSearchGroupAction -> {
val group = context.state.tabPartitions[SEARCH_TERM_TAB_GROUPS]?.getGroupByName(action.searchTerm)
group?.let {
context.dispatch(TabGroupAction.RemoveTabGroupAction(SEARCH_TERM_TAB_GROUPS, it.id))
}
}
is TabListAction.RestoreAction -> {
action.tabs.forEach { tab ->
tab.historyMetadata?.searchTerm?.let { searchTerm ->
context.dispatch(
TabGroupAction.AddTabAction(SEARCH_TERM_TAB_GROUPS, searchTerm, tab.id)
)
}
}
}
}
}
}

@ -253,6 +253,7 @@ class TabsTrayFragment : AppCompatDialogFragment() {
tabsTrayStore
),
store = requireContext().components.core.store,
defaultTabPartitionsFilter = { tabPartitions -> tabPartitions[SEARCH_TERM_TAB_GROUPS] }
),
owner = this,
view = view

@ -37,14 +37,15 @@ class TabsTrayMiddleware(
metrics.track(Event.TabsTrayHasInactiveTabs(action.tabs.size))
}
}
is TabsTrayAction.UpdateSearchGroupTabs -> {
is TabsTrayAction.UpdateTabPartitions -> {
if (shouldReportSearchGroupMetrics) {
shouldReportSearchGroupMetrics = false
val tabGroups = action.tabPartition?.tabGroups ?: emptyList()
metrics.track(Event.SearchTermGroupCount(action.groups.size))
metrics.track(Event.SearchTermGroupCount(tabGroups.size))
if (action.groups.isNotEmpty()) {
val tabsPerGroup = action.groups.map { it.tabs.size }
if (tabGroups.isNotEmpty()) {
val tabsPerGroup = tabGroups.map { it.tabIds.size }
val averageTabsPerGroup = tabsPerGroup.average()
metrics.track(Event.AverageTabsPerSearchTermGroup(averageTabsPerGroup))

@ -5,12 +5,12 @@
package org.mozilla.fenix.tabstray
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.fenix.tabstray.browser.TabGroup
/**
* Value type that represents the state of the tabs tray.
@ -19,8 +19,8 @@ import org.mozilla.fenix.tabstray.browser.TabGroup
* @property mode Whether the browser tab list is in multi-select mode or not with the set of
* currently selected tabs.
* @property inactiveTabs The list of tabs are considered inactive.
* @property searchTermGroups The list of tab groups.
* @property normalTabs The list of normal tabs that do not fall under [inactiveTabs] or [searchTermGroups].
* @property searchTermPartition The tab partition for search term groups.
* @property normalTabs The list of normal tabs that do not fall under [inactiveTabs] or search term groups.
* @property privateTabs The list of tabs that are [ContentState.private].
* @property syncing Whether the Synced Tabs feature should fetch the latest tabs from paired devices.
* @property focusGroupTabId The search group tab id to focus. Defaults to null.
@ -29,7 +29,7 @@ data class TabsTrayState(
val selectedPage: Page = Page.NormalTabs,
val mode: Mode = Mode.Normal,
val inactiveTabs: List<TabSessionState> = emptyList(),
val searchTermGroups: List<TabGroup> = emptyList(),
val searchTermPartition: TabPartition? = null,
val normalTabs: List<TabSessionState> = emptyList(),
val privateTabs: List<TabSessionState> = emptyList(),
val syncing: Boolean = false,
@ -142,9 +142,9 @@ sealed class TabsTrayAction : Action {
data class UpdateInactiveTabs(val tabs: List<TabSessionState>) : TabsTrayAction()
/**
* Updates the list of tab groups in [TabsTrayState.searchTermGroups].
* Updates the list of tab groups in [TabsTrayState.searchTermPartition].
*/
data class UpdateSearchGroupTabs(val groups: List<TabGroup>) : TabsTrayAction()
data class UpdateTabPartitions(val tabPartition: TabPartition?) : TabsTrayAction()
/**
* Updates the list of tabs in [TabsTrayState.normalTabs].
@ -189,8 +189,8 @@ internal object TabsTrayReducer {
state.copy(focusGroupTabId = null)
is TabsTrayAction.UpdateInactiveTabs ->
state.copy(inactiveTabs = action.tabs)
is TabsTrayAction.UpdateSearchGroupTabs ->
state.copy(searchTermGroups = action.groups)
is TabsTrayAction.UpdateTabPartitions ->
state.copy(searchTermPartition = action.tabPartition)
is TabsTrayAction.UpdateNormalTabs ->
state.copy(normalTabs = action.tabs)
is TabsTrayAction.UpdatePrivateTabs ->

@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import org.mozilla.fenix.components.Components
@ -82,7 +83,7 @@ class InactiveTabsAdapter(
}
}
override fun updateTabs(tabs: List<TabSessionState>, selectedTabId: String?) {
override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) {
inActiveTabsCount = tabs.size
// Early return with an empty list to remove the header/footer items.

@ -51,6 +51,6 @@ class InactiveTabsAutoCloseDialogController(
@VisibleForTesting
internal fun refreshInactiveTabsSection() {
val tabs = browserStore.state.tabs.filter(tabFilter)
tray.updateTabs(tabs, browserStore.state.selectedTabId)
tray.updateTabs(tabs, null, browserStore.state.selectedTabId)
}
}

@ -25,7 +25,7 @@ class InactiveTabsBinding(
.ifChanged()
.collect {
// We pass null for the selected tab id here, because inactive tabs doesn't care.
tray.updateTabs(it, null)
tray.updateTabs(it, null, null)
}
}
}

@ -71,6 +71,6 @@ class InactiveTabsController(
@VisibleForTesting
internal fun refreshInactiveTabsSection() {
val tabs = tabsTrayStore.state.inactiveTabs
tray.updateTabs(tabs, null)
tray.updateTabs(tabs, null, null)
}
}

@ -6,7 +6,6 @@ package org.mozilla.fenix.tabstray.browser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.lib.state.helpers.AbstractBinding
@ -23,11 +22,10 @@ class NormalTabsBinding(
private val tabsTray: TabsTray
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.normalTabs }
.ifChanged()
flow.ifChanged { Pair(it.normalTabs, it.searchTermPartition) }
.collect {
// Getting the selectedTabId from the BrowserStore at a different time might lead to a race.
tabsTray.updateTabs(it, browserStore.state.selectedTabId)
tabsTray.updateTabs(it.normalTabs, it.searchTermPartition, browserStore.state.selectedTabId)
}
}
}

@ -6,8 +6,9 @@ package org.mozilla.fenix.tabstray.browser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import mozilla.components.browser.state.state.isNotEmpty
import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
@ -19,9 +20,12 @@ class OtherHeaderBinding(
private val showHeader: (Boolean) -> Unit
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.ifAnyChanged { arrayOf(it.normalTabs, it.searchTermGroups) }
flow.ifChanged { Pair(it.normalTabs, it.searchTermPartition) }
.collect {
if (it.searchTermGroups.isNotEmpty() && it.normalTabs.isNotEmpty()) {
if (
it.normalTabs.isNotEmpty() &&
it.searchTermPartition.isNotEmpty()
) {
showHeader(true)
} else {
showHeader(false)

@ -27,7 +27,7 @@ class PrivateTabsBinding(
.ifChanged()
.collect {
// Getting the selectedTabId from the BrowserStore at a different time might lead to a race.
tray.updateTabs(it, browserStore.state.selectedTabId)
tray.updateTabs(it, null, browserStore.state.selectedTabId)
}
}
}

@ -1,26 +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 mozilla.components.browser.state.state.TabSessionState
data class TabGroup(
/**
* The search term used for the tab group.
* Not case dependant - searches with difference letter cases will be part of the same group.
* This property's value is then forced to start with an uppercase character.
*/
val searchTerm: String,
/**
* The list of tabSessionStates belonging to this tab group.
*/
val tabs: List<TabSessionState>,
/**
* The last time tabs in this group was accessed.
*/
val lastAccess: Long
)

@ -12,6 +12,8 @@ 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.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import org.mozilla.fenix.components.Components
@ -76,15 +78,15 @@ class TabGroupAdapter(
/**
* Not implemented; implementation is handled [List<Tab>.toSearchGroups]
*/
override fun updateTabs(tabs: List<TabSessionState>, selectedTabId: String?) =
override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) =
throw UnsupportedOperationException("Use submitList instead.")
private object DiffCallback : DiffUtil.ItemCallback<TabGroup>() {
override fun areItemsTheSame(oldItem: TabGroup, newItem: TabGroup) = oldItem.searchTerm == newItem.searchTerm
override fun areItemsTheSame(oldItem: TabGroup, newItem: TabGroup) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: TabGroup, newItem: TabGroup) = oldItem == newItem
}
}
internal fun TabGroup.containsTabId(tabId: String): Boolean {
return tabs.firstOrNull { it.id == tabId } != null
return tabIds.contains(tabId)
}

@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray.browser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.tabstray.TabsTrayState
@ -20,10 +21,10 @@ class TabGroupBinding(
private val tray: (List<TabGroup>) -> Unit
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.searchTermGroups }
flow.map { it.searchTermPartition?.tabGroups ?: emptyList() }
.ifChanged()
.collect {
tray.invoke(it)
tray.invoke(it.filter { tabGroup -> tabGroup.tabIds.isNotEmpty() })
}
}
}

@ -7,6 +7,8 @@ package org.mozilla.fenix.tabstray.browser
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabSessionState
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabGroupItemBinding
@ -36,12 +38,12 @@ class TabGroupViewHolder(
private lateinit var groupListAdapter: TabGroupListAdapter
fun bind(
group: TabGroup,
tabGroup: TabGroup,
) {
val selectedTabId = itemView.context.components.core.store.state.selectedTabId
val selectedIndex = group.tabs.indexOfFirst { it.id == selectedTabId }
val selectedIndex = tabGroup.tabIds.indexOfFirst { it == selectedTabId }
binding.tabGroupTitle.text = group.searchTerm
binding.tabGroupTitle.text = tabGroup.id
binding.tabGroupList.apply {
layoutManager = LinearLayoutManager(itemView.context, orientation, false)
groupListAdapter = TabGroupListAdapter(
@ -54,7 +56,11 @@ class TabGroupViewHolder(
adapter = groupListAdapter
groupListAdapter.submitList(group.tabs)
val tabGroupTabs = itemView.context.components.core.store.state.normalTabs.filter {
tabGroup.tabIds.contains(it.id)
}
groupListAdapter.submitList(tabGroupTabs)
scrollToPosition(selectedIndex)
}
}

@ -4,16 +4,16 @@
package org.mozilla.fenix.tabstray.browser
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.feature.tabs.tabstray.TabsFeature
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.ext.toSearchGroup
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS_MIN_SIZE
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.hasSearchTerm
import org.mozilla.fenix.tabstray.ext.isActive
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.utils.Settings
/**
@ -25,12 +25,14 @@ class TabSorter(
) : TabsTray {
private val groupsSet = mutableSetOf<String>()
override fun updateTabs(tabs: List<TabSessionState>, selectedTabId: String?) {
override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) {
val privateTabs = tabs.filter { it.content.private }
val allNormalTabs = tabs - privateTabs
val inactiveTabs = allNormalTabs.getInactiveTabs(settings)
val searchTermTabs = allNormalTabs.getSearchGroupTabs(settings)
val normalTabs = allNormalTabs - inactiveTabs - searchTermTabs
val tabGroups = tabPartition?.getTabGroups(settings, groupsSet) ?: emptyList()
val tabGroupTabIds = tabGroups.flatMap { it.tabIds }
val normalTabs = (allNormalTabs - inactiveTabs).filterNot { tabGroupTabIds.contains(it.id) }
val minTabPartition = tabPartition?.let { TabPartition(tabPartition.id, tabGroups) }
// Private tabs
tabsTrayStore?.dispatch(TabsTrayAction.UpdatePrivateTabs(privateTabs))
@ -38,16 +40,14 @@ class TabSorter(
// Inactive tabs
tabsTrayStore?.dispatch(TabsTrayAction.UpdateInactiveTabs(inactiveTabs))
// Tab groups
val (groups, remainderTabs) = searchTermTabs.toSearchGroup(groupsSet)
// Normal tabs
tabsTrayStore?.dispatch(TabsTrayAction.UpdateNormalTabs(normalTabs))
groupsSet.clear()
groupsSet.addAll(groups.map { it.searchTerm })
tabsTrayStore?.dispatch(TabsTrayAction.UpdateSearchGroupTabs(groups))
// Search term tabs
tabsTrayStore?.dispatch(TabsTrayAction.UpdateTabPartitions(minTabPartition))
// Normal tabs.
val totalNormalTabs = (normalTabs + remainderTabs)
tabsTrayStore?.dispatch(TabsTrayAction.UpdateNormalTabs(totalNormalTabs))
groupsSet.clear()
groupsSet.addAll(tabGroups.map { it.id })
}
}
@ -64,18 +64,14 @@ private fun List<TabSessionState>.getInactiveTabs(settings: Settings): List<TabS
}
/**
* Returns a list of search term tabs based on our preferences.
* Returns a list of tab groups based on our preferences.
*/
private fun List<TabSessionState>.getSearchGroupTabs(settings: Settings): List<TabSessionState> {
val inactiveTabsEnabled = settings.inactiveTabsAreEnabled
val tabGroupsEnabled = settings.searchTermTabGroupsAreEnabled
return when {
tabGroupsEnabled && inactiveTabsEnabled ->
filter { it.isNormalTabActiveWithSearchTerm(maxActiveTime) }
tabGroupsEnabled ->
filter { it.hasSearchTerm() }
else -> emptyList()
private fun TabPartition.getTabGroups(settings: Settings, groupsSet: Set<String>): List<TabGroup> {
return if (settings.searchTermTabGroupsAreEnabled) {
tabGroups.filter {
it.tabIds.size >= SEARCH_TERM_TAB_GROUPS_MIN_SIZE || groupsSet.contains(it.id)
}
} else {
emptyList()
}
}

@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray.browser
import androidx.annotation.CallSuper
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.browser.tabstray.TabsTray
@ -29,7 +30,7 @@ abstract class TabsAdapter<T : TabViewHolder>(
protected var styling: TabsTrayStyling = TabsTrayStyling()
@CallSuper
override fun updateTabs(tabs: List<TabSessionState>, selectedTabId: String?) {
override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) {
this.selectedTabId = selectedTabId
submitList(tabs)

@ -7,9 +7,11 @@ 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.TabGroup
import mozilla.components.browser.state.state.TabSessionState
import org.mozilla.fenix.ext.toSearchGroup
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS_MIN_SIZE
/**
* The currently selected tab if there's one that is private.
@ -39,17 +41,21 @@ fun BrowserState.getNormalTrayTabs(
searchTermTabGroupsAreEnabled: Boolean,
inactiveTabsEnabled: Boolean
): List<TabSessionState> {
val tabGroupsTabIds = getTabGroups()?.flatMap { it.tabIds } ?: emptyList()
return normalTabs.run {
if (searchTermTabGroupsAreEnabled && inactiveTabsEnabled) {
val remainderTabs = toSearchGroup().second
filter { it.isNormalTabActiveWithoutSearchTerm(maxActiveTime) } + remainderTabs
filter { it.isNormalTabActive(maxActiveTime) }.filter { tabGroupsTabIds.contains(it.id) }
} else if (inactiveTabsEnabled) {
filter { it.isNormalTabActive(maxActiveTime) }
} else if (searchTermTabGroupsAreEnabled) {
val remainderTabs = toSearchGroup().second
filter { it.isNormalTabWithSearchTerm() } + remainderTabs
filter { it.isNormalTab() }.filter { tabGroupsTabIds.contains(it.id) }
} else {
this
}
}
}
fun BrowserState.getTabGroups(): List<TabGroup>? {
return tabPartitions[SEARCH_TERM_TAB_GROUPS]?.tabGroups
?.filter { it.tabIds.size >= SEARCH_TERM_TAB_GROUPS_MIN_SIZE }
}

@ -26,14 +26,6 @@ internal fun TabSessionState.isNormalTabActive(maxActiveTime: Long): Boolean {
return isActive(maxActiveTime) && !content.private
}
/**
* 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.
*/
@ -49,15 +41,15 @@ internal fun TabSessionState.isNormalTabWithSearchTerm(): Boolean {
}
/**
* Returns true if the [TabSessionState] has a search term but may or may not be active.
* Returns true if the [TabSessionState] is considered active based on the [maxActiveTime].
*/
internal fun TabSessionState.isNormalTabWithoutSearchTerm(): Boolean {
return !hasSearchTerm() && !content.private
internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean {
return !isActive(maxActiveTime) && !content.private
}
/**
* Returns true if the [TabSessionState] is considered active based on the [maxActiveTime].
* Returns true if the [TabSessionState] is not private.
*/
internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean {
return !isActive(maxActiveTime) && !content.private
internal fun TabSessionState.isNormalTab(): Boolean {
return !content.private
}

@ -8,6 +8,8 @@ import io.mockk.every
import io.mockk.mockk
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.LastMediaAccessState
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.createTab
import mozilla.components.concept.storage.HistoryMetadataKey
import org.junit.Assert.assertEquals
@ -15,7 +17,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.tabstray.browser.TabGroup
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.utils.Settings
class BrowserStateTest {
@ -160,7 +162,7 @@ class BrowserStateTest {
@Test
fun `GIVEN only normal tabs from a search group are open WHEN recentTabs is called THEN return only the tab group`() {
val searchGroupTab = createTab(
val searchGroupTab1 = createTab(
url = "https://www.mozilla.org",
id = "1",
historyMetadata = HistoryMetadataKey(
@ -169,19 +171,30 @@ class BrowserStateTest {
referrerUrl = "https://www.mozilla.org"
)
)
val searchGroupTab2 = createTab(
url = "https://www.mozilla.org",
id = "2",
historyMetadata = HistoryMetadataKey(
url = "https://www.mozilla.org",
searchTerm = "Test",
referrerUrl = "https://www.mozilla.org"
)
)
val tabGroup = listOf(TabGroup("Test", "", listOf(searchGroupTab1.id, searchGroupTab2.id)))
val browserState = BrowserState(
tabs = listOf(searchGroupTab, searchGroupTab),
selectedTabId = searchGroupTab.id
tabs = listOf(searchGroupTab1, searchGroupTab2),
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, tabGroup))),
selectedTabId = searchGroupTab1.id
)
val result = browserState.asRecentTabs()
assertEquals(1, result.size)
assert(result[0] is RecentTab.SearchGroup)
assertEquals(searchGroupTab.historyMetadata?.searchTerm, (result[0] as RecentTab.SearchGroup).searchTerm)
assertEquals(searchGroupTab.id, (result[0] as RecentTab.SearchGroup).tabId)
assertEquals(searchGroupTab.content.url, (result[0] as RecentTab.SearchGroup).url)
assertEquals(searchGroupTab.content.thumbnail, (result[0] as RecentTab.SearchGroup).thumbnail)
assertEquals(searchGroupTab1.historyMetadata?.searchTerm, (result[0] as RecentTab.SearchGroup).searchTerm)
assertEquals(searchGroupTab1.id, (result[0] as RecentTab.SearchGroup).tabId)
assertEquals(searchGroupTab1.content.url, (result[0] as RecentTab.SearchGroup).url)
assertEquals(searchGroupTab1.content.thumbnail, (result[0] as RecentTab.SearchGroup).thumbnail)
assertEquals(2, (result[0] as RecentTab.SearchGroup).count)
}
@ -199,6 +212,7 @@ class BrowserStateTest {
val otherTab = createTab(url = "https://www.mozilla.org/firefox", id = "2")
val browserState = BrowserState(
tabs = listOf(searchGroupTab, otherTab, searchGroupTab),
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("Test", "", listOf("1", "3")))))),
selectedTabId = searchGroupTab.id
)
@ -207,7 +221,7 @@ class BrowserStateTest {
assertEquals(2, result.size)
assertEquals(otherTab, (result[0] as RecentTab.Tab).state)
assert(result[1] is RecentTab.SearchGroup)
assertEquals(searchGroupTab.historyMetadata?.searchTerm, (result[1] as RecentTab.SearchGroup).searchTerm)
assertEquals("Test", (result[1] as RecentTab.SearchGroup).searchTerm)
assertEquals(searchGroupTab.id, (result[1] as RecentTab.SearchGroup).tabId)
assertEquals(searchGroupTab.content.url, (result[1] as RecentTab.SearchGroup).url)
assertEquals(searchGroupTab.content.thumbnail, (result[1] as RecentTab.SearchGroup).thumbnail)
@ -242,14 +256,14 @@ class BrowserStateTest {
val result = browserState.asRecentTabs()
assertEquals(2, result.size)
assertEquals(1, result.size)
assertEquals(selectedTab, (result[0] as RecentTab.Tab).state)
}
@Test
fun `GIVEN the selected tab is a normal tab and tab group with two tabs exists WHEN asRecentTabs is called THEN return a list of these tabs`() {
val selectedTab = createTab(url = "url", id = "3")
val searchGroupTab = createTab(
val searchGroupTab1 = createTab(
url = "https://www.mozilla.org",
id = "4",
historyMetadata = HistoryMetadataKey(
@ -258,8 +272,19 @@ class BrowserStateTest {
referrerUrl = "https://www.mozilla.org"
)
)
val searchGroupTab2 = createTab(
url = "https://www.mozilla.org",
id = "5",
historyMetadata = HistoryMetadataKey(
url = "https://www.mozilla.org",
searchTerm = "Test",
referrerUrl = "https://www.mozilla.org"
)
)
val tabGroup = listOf(TabGroup("Test", "", listOf(searchGroupTab1.id, searchGroupTab2.id)))
val browserState = BrowserState(
tabs = listOf(mockk(relaxed = true), selectedTab, searchGroupTab, searchGroupTab),
tabs = listOf(mockk(relaxed = true), selectedTab, searchGroupTab1, searchGroupTab1),
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, tabGroup))),
selectedTabId = selectedTab.id
)
@ -268,10 +293,10 @@ class BrowserStateTest {
assertEquals(2, result.size)
assertEquals(selectedTab, (result[0] as RecentTab.Tab).state)
assert(result[1] is RecentTab.SearchGroup)
assertEquals(searchGroupTab.historyMetadata?.searchTerm, (result[1] as RecentTab.SearchGroup).searchTerm)
assertEquals(searchGroupTab.id, (result[1] as RecentTab.SearchGroup).tabId)
assertEquals(searchGroupTab.content.url, (result[1] as RecentTab.SearchGroup).url)
assertEquals(searchGroupTab.content.thumbnail, (result[1] as RecentTab.SearchGroup).thumbnail)
assertEquals(searchGroupTab1.historyMetadata?.searchTerm, (result[1] as RecentTab.SearchGroup).searchTerm)
assertEquals(searchGroupTab1.id, (result[1] as RecentTab.SearchGroup).tabId)
assertEquals(searchGroupTab1.content.url, (result[1] as RecentTab.SearchGroup).url)
assertEquals(searchGroupTab1.content.thumbnail, (result[1] as RecentTab.SearchGroup).thumbnail)
assertEquals(2, (result[1] as RecentTab.SearchGroup).count)
}
@ -447,14 +472,19 @@ class BrowserStateTest {
fun `GIVEN tabs exist with search terms WHEN lastTabGroup is called THEN return the last accessed TabGroup`() {
val tab1 = createTab(url = "url1", id = "id1", searchTerms = "test1", lastAccess = 10)
val tab2 = createTab(url = "url2", id = "id2", searchTerms = "test1", lastAccess = 11)
val tab3 = createTab(url = "url3", id = "id3", searchTerms = "test3", lastAccess = 1000)
val tab4 = createTab(url = "url4", id = "id4", searchTerms = "test3", lastAccess = 1111)
val tab5 = createTab(url = "url5", id = "id5", searchTerms = "test5", lastAccess = 100)
val tab6 = createTab(url = "url6", id = "id6", searchTerms = "test5", lastAccess = 111)
val tab3 = createTab(url = "url3", id = "id3", searchTerms = "test3", lastAccess = 100)
val tab4 = createTab(url = "url4", id = "id4", searchTerms = "test3", lastAccess = 111)
val tab5 = createTab(url = "url5", id = "id5", searchTerms = "test5", lastAccess = 1000)
val tab6 = createTab(url = "url6", id = "id6", searchTerms = "test5", lastAccess = 1111)
val tabGroup1 = TabGroup("test1", "", listOf(tab1.id, tab2.id))
val tabGroup2 = TabGroup("test3", "", listOf(tab3.id, tab4.id))
val tabGroup3 = TabGroup("test5", "", listOf(tab5.id, tab6.id))
val browserState = BrowserState(
tabs = listOf(tab1, tab2, tab3, tab4, tab5, tab6)
tabs = listOf(tab1, tab2, tab3, tab4, tab5, tab6),
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(tabGroup1, tabGroup2, tabGroup3))))
)
val expected = TabGroup("Test3", listOf(tab3, tab4), tab4.lastAccess)
val expected = TabGroup("test5", "", listOf(tab5.id, tab6.id))
val result = browserState.lastTabGroup

@ -14,6 +14,8 @@ import mozilla.components.browser.state.action.MediaSessionAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.LastMediaAccessState
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.mediasession.MediaSession
@ -35,6 +37,7 @@ import org.junit.Test
import org.mozilla.fenix.home.HomeFragmentAction.RecentTabsChange
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
class RecentTabsListFeatureTest {
@ -407,9 +410,11 @@ class RecentTabsListFeatureTest {
)
)
val tabs = listOf(tab1, tab2)
val tabGroup = TabGroup("Test search term", "", listOf(tab1.id, tab2.id))
val browserStore = BrowserStore(
BrowserState(
tabs = tabs,
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(tabGroup)))),
selectedTabId = "1"
)
)
@ -438,7 +443,7 @@ class RecentTabsListFeatureTest {
id = "1",
historyMetadata = HistoryMetadataKey(
url = "https://www.mozilla.org",
searchTerm = "test search term",
searchTerm = "Test search term",
referrerUrl = "https://www.mozilla.org"
)
)
@ -448,15 +453,17 @@ class RecentTabsListFeatureTest {
id = "2",
historyMetadata = HistoryMetadataKey(
url = "https://www.mozilla.org",
searchTerm = "test search term",
searchTerm = "Test search term",
referrerUrl = "https://www.mozilla.org"
)
)
val tab3 = createTab(url = "https://www.mozilla.org/firefox", id = "3")
val tabs = listOf(tab1, tab2, tab3)
val tabGroup = TabGroup("Test search term", "", listOf(tab1.id, tab2.id))
val browserStore = BrowserStore(
BrowserState(
tabs = tabs,
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(tabGroup)))),
selectedTabId = "1"
)
)
@ -542,9 +549,11 @@ class RecentTabsListFeatureTest {
)
)
val tabs = listOf(tab1, tab2, tab3)
val tabGroup = TabGroup("test search term", "", listOf(tab2.id, tab3.id))
val browserStore = BrowserStore(
BrowserState(
tabs = tabs,
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(tabGroup)))),
selectedTabId = "1"
)
)
@ -561,7 +570,7 @@ class RecentTabsListFeatureTest {
assertTrue(homeStore.state.recentTabs[0] is RecentTab.Tab)
assertEquals(tab1, (homeStore.state.recentTabs[0] as RecentTab.Tab).state)
val searchGroup = (homeStore.state.recentTabs[1] as RecentTab.SearchGroup)
assertEquals(searchGroup.searchTerm, "Test search term")
assertEquals(searchGroup.searchTerm, "test search term")
assertEquals(searchGroup.tabId, "2")
assertEquals(searchGroup.url, "https://www.mozilla.org")
assertEquals(searchGroup.thumbnail, null)
@ -593,9 +602,11 @@ class RecentTabsListFeatureTest {
thumbnail = thumbnail,
historyMetadata = historyMetadataKey
)
val searchTermTabGroup = TabGroup(historyMetadataKey.searchTerm!!, "", listOf(searchTermTab1.id, searchTermTab2.id))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(mediaTab, selectedTab, searchTermTab1, searchTermTab2),
tabPartitions = mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(searchTermTabGroup)))),
selectedTabId = "43"
)
)
@ -611,7 +622,7 @@ class RecentTabsListFeatureTest {
assertTrue(homeStore.state.recentTabs[0] is RecentTab.Tab)
assertEquals(selectedTab, (homeStore.state.recentTabs[0] as RecentTab.Tab).state)
val searchGroup = (homeStore.state.recentTabs[1] as RecentTab.SearchGroup)
assertEquals(searchGroup.searchTerm, "Test search term")
assertEquals(searchGroup.searchTerm, "test search term")
assertEquals(searchGroup.tabId, "44")
assertEquals(searchGroup.url, "https://www.mozilla.org")
assertEquals(searchGroup.thumbnail, thumbnail)

@ -0,0 +1,100 @@
/* 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
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.TabGroupAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.engine.EngineMiddleware
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.recover.RecoverableTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.lib.state.MiddlewareContext
import org.junit.Before
import org.junit.Test
class SearchTermTabGroupMiddlewareTest {
private lateinit var store: BrowserStore
private lateinit var searchTermTabGroupMiddleware: SearchTermTabGroupMiddleware
@Before
fun setUp() {
searchTermTabGroupMiddleware = SearchTermTabGroupMiddleware()
store = BrowserStore(
middleware = listOf(searchTermTabGroupMiddleware) + EngineMiddleware.create(engine = mockk()),
initialState = BrowserState()
)
}
@Test
fun `WHEN invoking with set history metadata key action THEN dispatch add tab action`() {
val context: MiddlewareContext<BrowserState, BrowserAction> = mockk()
val next: (BrowserAction) -> Unit = {}
every { context.dispatch(any()) } returns Unit
searchTermTabGroupMiddleware.invoke(
context,
next,
HistoryMetadataAction.SetHistoryMetadataKeyAction("tabId", HistoryMetadataKey("url", "search term", "url"))
)
verify { context.dispatch(TabGroupAction.AddTabAction(SEARCH_TERM_TAB_GROUPS, "search term", "tabId")) }
}
@Test
fun `WHEN invoking with disband search group action THEN dispatch remove tab group action`() {
val context: MiddlewareContext<BrowserState, BrowserAction> = mockk()
val next: (BrowserAction) -> Unit = {}
val state: BrowserState = mockk()
val tabPartitions =
mapOf(Pair(SEARCH_TERM_TAB_GROUPS, TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("testId", "search term", listOf("tab1"))))))
every { context.dispatch(any()) } returns Unit
every { context.state } returns state
every { state.tabPartitions } returns tabPartitions
searchTermTabGroupMiddleware.invoke(
context,
next,
HistoryMetadataAction.DisbandSearchGroupAction("search term")
)
verify { context.dispatch(TabGroupAction.RemoveTabGroupAction(SEARCH_TERM_TAB_GROUPS, "testId")) }
}
@Test
fun `WHEN invoking with restore action THEN dispatch add tab action`() {
val context: MiddlewareContext<BrowserState, BrowserAction> = mockk()
val next: (BrowserAction) -> Unit = {}
every { context.dispatch(any()) } returns Unit
searchTermTabGroupMiddleware.invoke(
context,
next,
TabListAction.RestoreAction(
listOf(
RecoverableTab(
id = "testId",
url = "url",
historyMetadata = HistoryMetadataKey("url", "search term", "url")
)
),
restoreLocation = TabListAction.RestoreAction.RestoreLocation.BEGINNING
)
)
verify { context.dispatch(TabGroupAction.AddTabAction(SEARCH_TERM_TAB_GROUPS, "search term", "testId")) }
}
}

@ -7,13 +7,14 @@ package org.mozilla.fenix.tabstray
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.tabstray.browser.TabGroup
class TabsTrayMiddlewareTest {
@ -35,21 +36,21 @@ class TabsTrayMiddlewareTest {
@Test
fun `WHEN search term groups are updated AND there is at least one group THEN report the average tabs per group`() {
store.dispatch(TabsTrayAction.UpdateSearchGroupTabs(generateSearchTermTabGroupsForAverage()))
store.dispatch(TabsTrayAction.UpdateTabPartitions(generateSearchTermTabGroupsForAverage()))
store.waitUntilIdle()
verify { metrics.track(Event.AverageTabsPerSearchTermGroup(5.0)) }
}
@Test
fun `WHEN search term groups are updated AND there is at least one group THEN report the distribution of tab sizes`() {
store.dispatch(TabsTrayAction.UpdateSearchGroupTabs(generateSearchTermTabGroupsForDistribution()))
store.dispatch(TabsTrayAction.UpdateTabPartitions(generateSearchTermTabGroupsForDistribution()))
store.waitUntilIdle()
verify { metrics.track(Event.SearchTermGroupSizeDistribution(listOf(3L, 2L, 1L, 4L))) }
}
@Test
fun `WHEN search term groups are updated THEN report the count of search term tab groups`() {
store.dispatch(TabsTrayAction.UpdateSearchGroupTabs(emptyList()))
store.dispatch(TabsTrayAction.UpdateTabPartitions(null))
store.waitUntilIdle()
verify { metrics.track(Event.SearchTermGroupCount(0)) }
}
@ -79,29 +80,29 @@ class TabsTrayMiddlewareTest {
assertEquals(4L, tabsTrayMiddleware.generateTabGroupSizeMappedValue(50))
}
private fun generateSearchTermTabGroupsForAverage(): List<TabGroup> {
val group1 = TabGroup("", mockk(relaxed = true), 0L)
val group2 = TabGroup("", mockk(relaxed = true), 0L)
val group3 = TabGroup("", mockk(relaxed = true), 0L)
private fun generateSearchTermTabGroupsForAverage(): TabPartition {
val group1 = TabGroup("", "", mockk(relaxed = true))
val group2 = TabGroup("", "", mockk(relaxed = true))
val group3 = TabGroup("", "", mockk(relaxed = true))
every { group1.tabs.size } returns 8
every { group2.tabs.size } returns 4
every { group3.tabs.size } returns 3
every { group1.tabIds.size } returns 8
every { group2.tabIds.size } returns 4
every { group3.tabIds.size } returns 3
return listOf(group1, group2, group3)
return TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(group1, group2, group3))
}
private fun generateSearchTermTabGroupsForDistribution(): List<TabGroup> {
val group1 = TabGroup("", mockk(relaxed = true), 0L)
val group2 = TabGroup("", mockk(relaxed = true), 0L)
val group3 = TabGroup("", mockk(relaxed = true), 0L)
val group4 = TabGroup("", mockk(relaxed = true), 0L)
private fun generateSearchTermTabGroupsForDistribution(): TabPartition {
val group1 = TabGroup("", "", mockk(relaxed = true))
val group2 = TabGroup("", "", mockk(relaxed = true))
val group3 = TabGroup("", "", mockk(relaxed = true))
val group4 = TabGroup("", "", mockk(relaxed = true))
every { group1.tabs.size } returns 8
every { group2.tabs.size } returns 4
every { group3.tabs.size } returns 2
every { group4.tabs.size } returns 12
every { group1.tabIds.size } returns 8
every { group2.tabIds.size } returns 4
every { group3.tabIds.size } returns 2
every { group4.tabIds.size } returns 12
return listOf(group1, group2, group3, group4)
return TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(group1, group2, group3, group4))
}
}

@ -40,6 +40,7 @@ class BrowserTabsAdapterTest {
listOf(
createTab(url = "url", id = "tab1")
),
null,
selectedTabId = "tab1"
)
@ -79,6 +80,7 @@ class BrowserTabsAdapterTest {
adapter.updateTabs(
listOf(tab),
null,
selectedTabId = "tab1"
)

@ -40,7 +40,7 @@ class InactiveTabsBindingTest {
assertTrue(store.state.inactiveTabs.isNotEmpty())
verify { tray.updateTabs(any(), any()) }
verify { tray.updateTabs(any(), any(), any()) }
}
@Test
@ -53,6 +53,6 @@ class InactiveTabsBindingTest {
assertTrue(store.state.inactiveTabs.isEmpty())
verify { tray.updateTabs(emptyList(), null) }
verify { tray.updateTabs(emptyList(), null, null) }
}
}

@ -50,7 +50,7 @@ class InactiveTabsControllerTest {
appStore.waitUntilIdle()
verify { tray.updateTabs(capture(tabsSlot), any()) }
verify { tray.updateTabs(capture(tabsSlot), null, any()) }
assertEquals(2, tabsSlot.captured.size)
assertEquals("1", tabsSlot.captured.first().id)

@ -49,7 +49,7 @@ class NormalTabsBindingTest {
assertTrue(store.state.normalTabs.isNotEmpty())
verify { tray.updateTabs(capture(slotTabs), "1") }
verify { tray.updateTabs(capture(slotTabs), null, "1") }
assertEquals(expectedTabs, slotTabs.captured)
}
@ -63,6 +63,6 @@ class NormalTabsBindingTest {
assertTrue(store.state.normalTabs.isEmpty())
verify { tray.updateTabs(emptyList(), "1") }
verify { tray.updateTabs(emptyList(), null, "1") }
}
}

@ -5,12 +5,15 @@
package org.mozilla.fenix.tabstray.browser
import io.mockk.mockk
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
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
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
@ -34,7 +37,7 @@ class OtherHeaderBindingTest {
@Test
fun `WHEN tabs for only groups THEN show no header`() {
val store = TabsTrayStore(TabsTrayState(searchTermGroups = listOf(mockk())))
val store = TabsTrayStore(TabsTrayState(searchTermPartition = mockk()))
var result: Boolean? = null
val binding = OtherHeaderBinding(store) { result = it }
@ -60,14 +63,20 @@ class OtherHeaderBindingTest {
@Test
fun `WHEN normal tabs and groups exist THEN show header`() {
val store = TabsTrayStore(TabsTrayState(normalTabs = listOf(mockk()), searchTermGroups = listOf(mockk())))
var result: Boolean? = null
val tabGroup = TabGroup("test", "", listOf("1", "2"))
val store = TabsTrayStore(
TabsTrayState(
normalTabs = listOf(mockk()),
searchTermPartition = TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(tabGroup))
)
)
var result = false
val binding = OtherHeaderBinding(store) { result = it }
binding.start()
store.waitUntilIdle()
assertTrue(result!!)
assertTrue(result)
}
}

@ -49,7 +49,7 @@ class PrivateTabsBindingTest {
assertTrue(store.state.privateTabs.isNotEmpty())
verify { tray.updateTabs(capture(slotTabs), "1") }
verify { tray.updateTabs(capture(slotTabs), null, "1") }
assertEquals(expectedTabs, slotTabs.captured)
}
@ -64,6 +64,6 @@ class PrivateTabsBindingTest {
assertTrue(store.state.privateTabs.isEmpty())
verify { tray.updateTabs(emptyList(), "1") }
verify { tray.updateTabs(emptyList(), null, "1") }
}
}

@ -4,14 +4,18 @@
package org.mozilla.fenix.tabstray.browser
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.createTab
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayStore
@ -30,28 +34,44 @@ class TabGroupBindingTest {
@Test
fun `WHEN the store is updated THEN notify the adapter`() {
val expectedGroups = listOf(TabGroup("cats", emptyList(), 0))
val expectedTabGroups = listOf(TabGroup("cats", "name", listOf("1", "2")))
val tabPartition = TabPartition(SEARCH_TERM_TAB_GROUPS, expectedTabGroups)
assertTrue(store.state.searchTermGroups.isEmpty())
assertNull(store.state.searchTermPartition?.tabGroups)
store.dispatch(TabsTrayAction.UpdateSearchGroupTabs(expectedGroups)).joinBlocking()
store.dispatch(TabsTrayAction.UpdateTabPartitions(tabPartition)).joinBlocking()
binding.start()
assertTrue(store.state.searchTermGroups.isNotEmpty())
assertTrue(store.state.searchTermPartition?.tabGroups?.isNotEmpty() == true)
assertEquals(expectedGroups, captured)
assertEquals(expectedTabGroups, captured)
}
@Test
fun `WHEN the store is updated with empty tab group THEN notify the adapter`() {
val expectedTabPartition = TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("cats", "name", emptyList())))
assertNull(store.state.searchTermPartition?.tabGroups)
store.dispatch(TabsTrayAction.UpdateTabPartitions(expectedTabPartition)).joinBlocking()
binding.start()
assertTrue(store.state.searchTermPartition?.tabGroups?.isNotEmpty() == true)
assertEquals(emptyList<TabGroup>(), captured)
}
@Test
fun `WHEN non-group tabs are updated THEN do not notify the adapter`() {
assertTrue(store.state.searchTermGroups.isEmpty())
assertEquals(store.state.searchTermPartition?.tabGroups, null)
store.dispatch(TabsTrayAction.UpdatePrivateTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
binding.start()
assertTrue(store.state.searchTermGroups.isEmpty())
assertNull(store.state.searchTermPartition?.tabGroups)
assertEquals(emptyList<TabGroup>(), captured)
}

@ -6,11 +6,14 @@ package org.mozilla.fenix.tabstray.browser
import io.mockk.every
import io.mockk.mockk
import mozilla.components.browser.state.state.TabGroup
import mozilla.components.browser.state.state.TabPartition
import mozilla.components.browser.state.state.createTab
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.tabstray.SEARCH_TERM_TAB_GROUPS
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.utils.Settings
@ -33,79 +36,59 @@ class TabSorterTest {
listOf(
createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis())
),
null,
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 0)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, null)
assertEquals(tabsTrayStore.state.normalTabs.size, 1)
}
@Test
fun `WHEN updated with one normal tab and two search term tab THEN adapter have normal tab and a search group`() {
val tabSorter = TabSorter(settings, tabsTrayStore)
val searchTab1 = createTab(url = "url", id = "tab2", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
val searchTab2 = createTab(url = "url", id = "tab3", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
tabSorter.updateTabs(
listOf(
createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
createTab(
url = "url",
id = "tab2",
lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
),
createTab(
url = "url",
id = "tab3",
lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
searchTab1, searchTab2
),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf(searchTab1.id, searchTab2.id)))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 1)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 1)
assertEquals(tabsTrayStore.state.normalTabs.size, 1)
}
@Test
fun `WHEN updated with one normal tab, one inactive tab and two search term tab THEN adapter have normal tab, inactive tab and a search group`() {
val tabSorter = TabSorter(settings, tabsTrayStore)
val searchTab1 = createTab(url = "url", id = "tab3", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
val searchTab2 = createTab(url = "url", id = "tab4", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
tabSorter.updateTabs(
listOf(
createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
createTab(
url = "url",
id = "tab2",
lastAccess = inactiveTimestamp,
createdAt = inactiveTimestamp
),
createTab(
url = "url",
id = "tab3",
lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
),
createTab(
url = "url",
id = "tab4",
lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
createTab(url = "url", id = "tab2", lastAccess = inactiveTimestamp, createdAt = inactiveTimestamp),
searchTab1, searchTab2
),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf(searchTab1.id, searchTab2.id)))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 1)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 1)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 1)
assertEquals(tabsTrayStore.state.normalTabs.size, 1)
}
@ -136,13 +119,14 @@ class TabSorterTest {
searchTerms = "mozilla"
)
),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf("tab3", "tab4")))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 1)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 1)
assertEquals(tabsTrayStore.state.normalTabs.size, 2)
}
@ -173,13 +157,14 @@ class TabSorterTest {
searchTerms = "mozilla"
)
),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf("tab3", "tab4")))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 1)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 0)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 0)
assertEquals(tabsTrayStore.state.normalTabs.size, 3)
}
@ -210,75 +195,67 @@ class TabSorterTest {
searchTerms = "mozilla"
)
),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", mockk()), TabGroup("mozilla", "", mockk()))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 0)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 0)
assertEquals(tabsTrayStore.state.normalTabs.size, 4)
}
@Test
fun `WHEN only one search term tab THEN there is no search group`() {
val tabSorter = TabSorter(settings, tabsTrayStore)
val tab1 =
createTab(
url = "url", id = "tab1", lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
tabSorter.updateTabs(
listOf(
createTab(
url = "url", id = "tab1", lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
),
listOf(tab1),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf(tab1.id)))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 0)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 0)
assertEquals(tabsTrayStore.state.normalTabs.size, 1)
}
@Test
fun `WHEN remove second last one search term tab THEN search group is kept even if there's only one tab`() {
val tabSorter = TabSorter(settings, tabsTrayStore)
val tab1 = createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
val tab2 = createTab(url = "url", id = "tab2", lastAccess = System.currentTimeMillis(), searchTerms = "mozilla")
tabSorter.updateTabs(
listOf(
createTab(
url = "url", id = "tab1", lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
),
createTab(
url = "url", id = "tab2", lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
),
listOf(tab1, tab2),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf(tab1.id, tab2.id)))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 1)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 1)
assertEquals(tabsTrayStore.state.normalTabs.size, 0)
tabSorter.updateTabs(
listOf(
createTab(
url = "url", id = "tab1", lastAccess = System.currentTimeMillis(),
searchTerms = "mozilla"
)
),
listOf(tab1),
TabPartition(SEARCH_TERM_TAB_GROUPS, listOf(TabGroup("mozilla", "", listOf(tab1.id)))),
selectedTabId = "tab1"
)
tabsTrayStore.waitUntilIdle()
assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
assertEquals(tabsTrayStore.state.searchTermGroups.size, 1)
assertEquals(tabsTrayStore.state.searchTermPartition?.tabGroups?.size, 1)
assertEquals(tabsTrayStore.state.normalTabs.size, 0)
}
}

@ -48,7 +48,7 @@ class AbstractBrowserPageViewHolderTest {
viewHolder.bind(adapter)
viewHolder.attachedToWindow()
adapter.updateTabs(listOf(createTab(url = "url", id = "tab1")), "tab1")
adapter.updateTabs(listOf(createTab(url = "url", id = "tab1")), null, "tab1")
assertTrue(trayList.visibility == VISIBLE)
assertTrue(emptyList.visibility == GONE)
@ -65,7 +65,7 @@ class AbstractBrowserPageViewHolderTest {
viewHolder.bind(adapter)
viewHolder.attachedToWindow()
adapter.updateTabs(emptyList(), "")
adapter.updateTabs(emptyList(), null, "")
assertTrue(trayList.visibility == GONE)
assertTrue(emptyList.visibility == VISIBLE)

@ -3,5 +3,5 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
object AndroidComponents {
const val VERSION = "98.0.20220111190103"
const val VERSION = "98.0.20220112232536"
}

Loading…
Cancel
Save