For #21897 - Convert inactive tabs to compose

pull/543/head
Noah Bond 2 years ago committed by mergify[bot]
parent 0cbf4d9b7f
commit 08a84f8353

@ -17,6 +17,7 @@ import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -228,11 +229,12 @@ class TabsTrayFragment : AppCompatDialogFragment() {
setupMenu(navigationInteractor)
setupPager(
view.context,
tabsTrayStore,
tabsTrayInteractor,
browserTrayInteractor,
navigationInteractor
context = view.context,
lifecycleOwner = viewLifecycleOwner,
store = tabsTrayStore,
trayInteractor = tabsTrayInteractor,
browserInteractor = browserTrayInteractor,
navigationInteractor = navigationInteractor,
)
setupBackgroundDismissalListener {
@ -467,8 +469,10 @@ class TabsTrayFragment : AppCompatDialogFragment() {
}
@VisibleForTesting
@Suppress("LongParameterList")
internal fun setupPager(
context: Context,
lifecycleOwner: LifecycleOwner,
store: TabsTrayStore,
trayInteractor: TabsTrayInteractor,
browserInteractor: BrowserTrayInteractor,
@ -476,13 +480,14 @@ class TabsTrayFragment : AppCompatDialogFragment() {
) {
tabsTrayBinding.tabsTray.apply {
adapter = TrayPagerAdapter(
context,
store,
browserInteractor,
navigationInteractor,
trayInteractor,
requireComponents.core.store,
requireComponents.appStore,
context = context,
lifecycleOwner = lifecycleOwner,
tabsTrayStore = store,
browserInteractor = browserInteractor,
navInteractor = navigationInteractor,
tabsTrayInteractor = trayInteractor,
browserStore = requireComponents.core.store,
appStore = requireComponents.appStore,
)
isUserInputEnabled = false
}

@ -9,14 +9,18 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.DefaultInactiveTabsInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter
import org.mozilla.fenix.tabstray.browser.InactiveTabsController
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter
import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter
import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder
@ -27,10 +31,11 @@ import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder
@Suppress("LongParameterList")
class TrayPagerAdapter(
@VisibleForTesting internal val context: Context,
@VisibleForTesting internal val lifecycleOwner: LifecycleOwner,
@VisibleForTesting internal val tabsTrayStore: TabsTrayStore,
@VisibleForTesting internal val browserInteractor: BrowserTrayInteractor,
@VisibleForTesting internal val navInteractor: NavigationInteractor,
@VisibleForTesting internal val interactor: TabsTrayInteractor,
@VisibleForTesting internal val tabsTrayInteractor: TabsTrayInteractor,
@VisibleForTesting internal val browserStore: BrowserStore,
@VisibleForTesting internal val appStore: AppStore
) : RecyclerView.Adapter<AbstractPageViewHolder>() {
@ -42,12 +47,29 @@ class TrayPagerAdapter(
*/
private val normalAdapter by lazy {
ConcatAdapter(
InactiveTabsAdapter(context, browserInteractor, interactor, INACTIVE_TABS_FEATURE_NAME, context.settings()),
InactiveTabsAdapter(
lifecycleOwner = lifecycleOwner,
tabsTrayStore = tabsTrayStore,
tabsTrayInteractor = tabsTrayInteractor,
inactiveTabsInteractor = inactiveTabsInteractor,
featureName = INACTIVE_TABS_FEATURE_NAME,
),
TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME),
TitleHeaderAdapter(),
BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME)
)
}
private val inactiveTabsInteractor by lazy {
DefaultInactiveTabsInteractor(
InactiveTabsController(
appStore = context.components.appStore,
settings = context.settings(),
browserInteractor = browserInteractor,
)
)
}
private val privateAdapter by lazy {
BrowserTabsAdapter(
context,
@ -62,10 +84,11 @@ class TrayPagerAdapter(
NormalBrowserPageViewHolder.LAYOUT_ID -> {
NormalBrowserPageViewHolder(
LayoutInflater.from(parent.context).inflate(viewType, parent, false),
lifecycleOwner,
tabsTrayStore,
browserStore,
appStore,
interactor
tabsTrayInteractor
)
}
PrivateBrowserPageViewHolder.LAYOUT_ID -> {
@ -73,7 +96,7 @@ class TrayPagerAdapter(
LayoutInflater.from(parent.context).inflate(viewType, parent, false),
tabsTrayStore,
browserStore,
interactor
tabsTrayInteractor
)
}
SyncedTabsPageViewHolder.LAYOUT_ID -> {

@ -5,165 +5,95 @@
package org.mozilla.fenix.tabstray.browser
import android.view.View
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.InactiveFooterItemBinding
import org.mozilla.fenix.databinding.InactiveHeaderItemBinding
import org.mozilla.fenix.databinding.InactiveTabListItemBinding
import org.mozilla.fenix.databinding.InactiveTabsAutoCloseBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.home.topsites.dpToPx
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import org.mozilla.fenix.components.components
import org.mozilla.fenix.compose.ComposeViewHolder
import org.mozilla.fenix.tabstray.TabsTrayFragment
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TrayPagerAdapter
import org.mozilla.fenix.tabstray.inactivetabs.InactiveTabsList
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class HeaderHolder(
itemView: View,
inactiveTabsInteractor: InactiveTabsInteractor,
tabsTrayInteractor: TabsTrayInteractor,
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveHeaderItemBinding.bind(itemView)
init {
itemView.apply {
isActivated = itemView.context.components.appStore.state.inactiveTabsExpanded
correctHeaderBorder(isActivated)
setOnClickListener {
val newState = !it.isActivated
inactiveTabsInteractor.onHeaderClicked(newState)
it.isActivated = newState
correctHeaderBorder(isActivated)
}
binding.delete.setOnClickListener {
tabsTrayInteractor.onDeleteInactiveTabs()
}
}
}
/**
* When the header is collapsed we use its bottom border instead of the footer's
*/
private fun correctHeaderBorder(isActivated: Boolean) {
binding.inactiveHeaderBorder.updatePadding(
bottom = binding.root.context.dpToPx(if (isActivated) 0f else 1f)
)
}
companion object {
const val LAYOUT_ID = R.layout.inactive_header_item
}
}
class AutoCloseDialogHolder(
itemView: View,
interactor: InactiveTabsAutoCloseDialogInteractor
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveTabsAutoCloseBinding.bind(itemView)
init {
/**
* The [ComposeViewHolder] for displaying the section of inactive tabs in [TrayPagerAdapter].
*
* @param composeView [ComposeView] which will be populated with Jetpack Compose UI content.
* @param lifecycleOwner [LifecycleOwner] to which this Composable will be tied to.
* @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs].
* @param tabsTrayInteractor [TabsTrayInteractor] used to handle deleting all inactive tabs.
* @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header
* and the auto close dialog.
*/
@Suppress("LongParameterList")
class InactiveTabViewHolder(
composeView: ComposeView,
lifecycleOwner: LifecycleOwner,
private val tabsTrayStore: TabsTrayStore,
private val tabsTrayInteractor: TabsTrayInteractor,
private val inactiveTabsInteractor: InactiveTabsInteractor,
) : ComposeViewHolder(composeView, lifecycleOwner) {
@Composable
override fun Content() {
val expanded = components.appStore
.observeAsComposableState { state -> state.inactiveTabsExpanded }.value ?: false
val inactiveTabs = tabsTrayStore
.observeAsComposableState { state -> state.inactiveTabs }.value ?: emptyList()
val showInactiveTabsAutoCloseDialog =
components.settings.shouldShowInactiveTabsAutoCloseDialog(inactiveTabs.size)
var showAutoClosePrompt by remember { mutableStateOf(showInactiveTabsAutoCloseDialog) }
if (showInactiveTabsAutoCloseDialog) {
TabsTrayMetrics.autoCloseSeen.record(NoExtras())
binding.message.text = with(binding.root.context) {
getString(
R.string.tab_tray_inactive_auto_close_body_2,
getString(R.string.app_name)
)
}
binding.closeButton.setOnClickListener {
interactor.onCloseClicked()
}
binding.action.setOnClickListener {
interactor.onEnabledAutoCloseClicked()
showConfirmationSnackbar()
}
}
private fun showConfirmationSnackbar() {
val context = binding.root.context
val view = binding.root
val text = context.getString(R.string.inactive_tabs_auto_close_message_snackbar)
val snackbar = FenixSnackbar.make(
view = view,
duration = FenixSnackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = true
).setText(text)
snackbar.view.elevation = TabsTrayFragment.ELEVATION
snackbar.show()
}
companion object {
const val LAYOUT_ID = R.layout.inactive_tabs_auto_close
if (inactiveTabs.isNotEmpty()) {
InactiveTabsList(
inactiveTabs = inactiveTabs,
expanded = expanded,
showAutoCloseDialog = showAutoClosePrompt,
onHeaderClick = { inactiveTabsInteractor.onHeaderClicked(!expanded) },
onDeleteAllButtonClick = tabsTrayInteractor::onDeleteInactiveTabs,
onAutoCloseDismissClick = {
inactiveTabsInteractor.onCloseClicked()
showAutoClosePrompt = !showAutoClosePrompt
},
onEnableAutoCloseClick = {
inactiveTabsInteractor.onEnabledAutoCloseClicked()
showAutoClosePrompt = !showAutoClosePrompt
showConfirmationSnackbar()
},
onTabClick = inactiveTabsInteractor::onTabClicked,
onTabCloseClick = inactiveTabsInteractor::onTabClosed,
)
}
}
/**
* A RecyclerView ViewHolder implementation for an inactive tab view.
*
* @param itemView the inactive tab [View].
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class TabViewHolder(
itemView: View,
private val delegate: TabsTray.Delegate,
private val featureName: String
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveTabListItemBinding.bind(itemView)
fun bind(tab: TabSessionState) {
val components = itemView.context.components
val title = tab.content.title.ifEmpty { tab.content.url.take(MAX_URI_LENGTH) }
val url = tab.content.url.toShortUrl(components.publicSuffixList).take(MAX_URI_LENGTH)
itemView.setOnClickListener {
TabsTrayMetrics.openInactiveTab.add()
delegate.onTabSelected(tab, featureName)
}
binding.siteListItem.apply {
components.core.icons.loadIntoView(iconView, tab.content.url)
setText(title, url)
setSecondaryButton(
R.drawable.mozac_ic_close,
R.string.content_description_close_button
) {
TabsTrayMetrics.closeInactiveTab.add()
delegate.onTabClosed(tab, featureName)
}
}
}
companion object {
const val LAYOUT_ID = R.layout.inactive_tab_list_item
}
private fun showConfirmationSnackbar() {
val context = composeView.context
val text = context.getString(R.string.inactive_tabs_auto_close_message_snackbar)
val snackbar = FenixSnackbar.make(
view = composeView,
duration = FenixSnackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = true
).setText(text)
snackbar.view.elevation = TabsTrayFragment.ELEVATION
snackbar.show()
}
class FooterHolder(itemView: View) : InactiveTabViewHolder(itemView) {
init {
InactiveFooterItemBinding.bind(itemView)
}
companion object {
const val LAYOUT_ID = R.layout.inactive_footer_item
}
companion object {
val LAYOUT_ID = View.generateViewId()
}
}

@ -4,149 +4,57 @@
package org.mozilla.fenix.tabstray.browser
import android.content.Context
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
import org.mozilla.fenix.ext.components
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.AutoCloseDialogHolder
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.FooterHolder
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.TabViewHolder
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
/**
* A convenience alias for readability.
*/
private typealias Adapter = ListAdapter<InactiveTabsAdapter.Item, InactiveTabViewHolder>
/**
* The [ListAdapter] for displaying the list of inactive tabs.
* The adapter for displaying the section of inactive tabs.
*
* @param context [Context] used for various platform interactions or accessing [Components]
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param lifecycleOwner [LifecycleOwner] to which the Composable will be tied to.
* @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs].
* @param tabsTrayInteractor [TabsTrayInteractor] used to handle deleting all inactive tabs.
* @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header
* and the auto close dialog.
* @param featureName [String] representing the name of the inactive tabs feature for telemetry reporting.
*/
@Suppress("LongParameterList")
class InactiveTabsAdapter(
private val context: Context,
private val browserTrayInteractor: BrowserTrayInteractor,
private val lifecycleOwner: LifecycleOwner,
private val tabsTrayStore: TabsTrayStore,
private val tabsTrayInteractor: TabsTrayInteractor,
private val inactiveTabsInteractor: InactiveTabsInteractor,
override val featureName: String,
private val settings: Settings,
) : Adapter(DiffCallback), TabsTray, FeatureNameHolder {
) : RecyclerView.Adapter<InactiveTabViewHolder>(), FeatureNameHolder {
internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor
private var inActiveTabsCount: Int = 0
override fun getItemCount(): Int = 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(viewType, parent, false)
return when (viewType) {
AutoCloseDialogHolder.LAYOUT_ID -> AutoCloseDialogHolder(view, inactiveTabsInteractor)
HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor, tabsTrayInteractor)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor, featureName)
FooterHolder.LAYOUT_ID -> FooterHolder(view)
else -> throw IllegalStateException("Unknown viewType: $viewType")
}
return InactiveTabViewHolder(
composeView = ComposeView(parent.context),
lifecycleOwner = lifecycleOwner,
tabsTrayStore = tabsTrayStore,
inactiveTabsInteractor = inactiveTabsInteractor,
tabsTrayInteractor = tabsTrayInteractor,
)
}
override fun onBindViewHolder(holder: InactiveTabViewHolder, position: Int) {
when (holder) {
is TabViewHolder -> {
val item = getItem(position) as Item.Tab
holder.bind(item.tab)
}
is FooterHolder, is HeaderHolder, is AutoCloseDialogHolder -> {
// do nothing.
}
}
}
override fun getItemViewType(position: Int): Int {
return when (position) {
0 -> HeaderHolder.LAYOUT_ID
1 -> if (settings.shouldShowInactiveTabsAutoCloseDialog(inActiveTabsCount)) {
AutoCloseDialogHolder.LAYOUT_ID
} else {
TabViewHolder.LAYOUT_ID
}
itemCount - 1 -> FooterHolder.LAYOUT_ID
else -> TabViewHolder.LAYOUT_ID
}
}
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.
if (tabs.isEmpty()) {
submitList(emptyList())
return
}
// If we have items, but we should be in a collapsed state.
if (!context.components.appStore.state.inactiveTabsExpanded) {
submitList(listOf(Item.Header))
return
}
val items = tabs.map { Item.Tab(it) }
val footer = Item.Footer
val headerItems = if (settings.shouldShowInactiveTabsAutoCloseDialog(items.size)) {
listOf(Item.Header, Item.AutoCloseMessage)
} else {
listOf(Item.Header)
}
submitList(headerItems + items + listOf(footer))
// no-op. This ViewHolder receives the TabsTrayStore as argument and will observe that
// without the need for us to manually update here for the data to be displayed.
}
private object DiffCallback : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return if (oldItem is Item.Tab && newItem is Item.Tab) {
oldItem.tab.id == newItem.tab.id
} else {
oldItem == newItem
}
}
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem == newItem
}
}
/**
* The types of different data we can put into the [InactiveTabsAdapter].
*/
sealed class Item {
/**
* A title header for the inactive tab section. This may be seen only
* when at least one inactive tab is present.
*/
object Header : Item()
/**
* A tab that is now considered inactive.
*/
data class Tab(val tab: TabSessionState) : Item()
/**
* A dialog for when the inactive tabs section reach 20 tabs.
*/
object AutoCloseMessage : Item()
override fun getItemViewType(position: Int): Int = InactiveTabViewHolder.LAYOUT_ID
/**
* A footer for the inactive tab section. This may be seen only
* when at least one inactive tab is present.
*/
object Footer : Item()
override fun onViewRecycled(holder: InactiveTabViewHolder) {
// no op
// This previously called "composeView.disposeComposition" which would have the
// entire Composable destroyed and recreated when this View is scrolled off or on screen again.
// This View already listens and maps store updates. Avoid creating and binding new Views.
// The composition will live until the ViewTreeLifecycleOwner to which it's attached to is destroyed.
}
}

@ -1,31 +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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
/**
* An inactive tabs observer that updates the provided [TabsTray].
*/
class InactiveTabsBinding(
store: TabsTrayStore,
private val tray: TabsTray
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.inactiveTabs }
.ifChanged()
.collect {
// We pass null for the selected tab id here, because inactive tabs doesn't care.
tray.updateTabs(it, null, null)
}
}
}

@ -4,31 +4,50 @@
package org.mozilla.fenix.tabstray.browser
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.browser.state.state.TabSessionState
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.UpdateInactiveExpanded
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.INACTIVE_TABS_FEATURE_NAME
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
/**
* Default behavior for handling all user interactions with the Inactive Tabs feature.
*
* @param appStore [AppStore] used to dispatch any [AppAction].
* @param settings [Settings] used to update any user preferences.
* @param browserInteractor [BrowserTrayInteractor] used to respond to interactions with specific inactive tabs.
*/
class InactiveTabsController(
private val tabsTrayStore: TabsTrayStore,
private val appStore: AppStore,
private val tray: TabsTray,
private val settings: Settings
private val settings: Settings,
private val browserInteractor: BrowserTrayInteractor,
) {
/**
* Opens the given inactive tab.
*/
fun openInactiveTab(tab: TabSessionState) {
TabsTrayMetrics.openInactiveTab.add()
browserInteractor.onTabSelected(tab, INACTIVE_TABS_FEATURE_NAME)
}
/**
* Closes the given inactive tab.
*/
fun closeInactiveTab(tab: TabSessionState) {
TabsTrayMetrics.closeInactiveTab.add()
browserInteractor.onTabClosed(tab, INACTIVE_TABS_FEATURE_NAME)
}
/**
* Updates the inactive card to be expanded to display all the tabs, or collapsed with only
* the title showing.
*/
fun updateCardExpansion(isExpanded: Boolean) {
appStore.dispatch(UpdateInactiveExpanded(isExpanded)).invokeOnCompletion {
// To avoid racing, we read the list of inactive tabs only after we have updated
// the expanded state.
refreshInactiveTabsSection()
}
appStore.dispatch(UpdateInactiveExpanded(isExpanded))
when (isExpanded) {
true -> TabsTrayMetrics.inactiveTabsExpanded.record(NoExtras())
@ -41,7 +60,6 @@ class InactiveTabsController(
*/
fun close() {
markDialogAsShown()
refreshInactiveTabsSection()
TabsTrayMetrics.autoCloseDimissed.record(NoExtras())
}
@ -54,7 +72,6 @@ class InactiveTabsController(
settings.closeTabsAfterOneWeek = false
settings.closeTabsAfterOneDay = false
settings.manuallyCloseTabs = false
refreshInactiveTabsSection()
TabsTrayMetrics.autoCloseTurnOnClicked.record(NoExtras())
}
@ -64,10 +81,4 @@ class InactiveTabsController(
private fun markDialogAsShown() {
settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true
}
@VisibleForTesting
internal fun refreshInactiveTabsSection() {
val tabs = tabsTrayStore.state.inactiveTabs
tray.updateTabs(tabs, null, null)
}
}

@ -4,16 +4,32 @@
package org.mozilla.fenix.tabstray.browser
import mozilla.components.browser.state.state.TabSessionState
/**
* Interactor for all things related to inactive tabs in the tabs tray.
*/
interface InactiveTabsInteractor : InactiveTabsAutoCloseDialogInteractor {
/**
* Invoked when the header is tapped on.
* Invoked when the header is clicked.
*
* @param activated true when the tap should expand the inactive section.
*/
fun onHeaderClicked(activated: Boolean)
/**
* Invoked when an inactive tab is clicked.
*
* @param tab [TabSessionState] that was clicked.
*/
fun onTabClicked(tab: TabSessionState)
/**
* Invoked when an inactive tab is closed.
*
* @param tab [TabSessionState] that was closed.
*/
fun onTabClosed(tab: TabSessionState)
}
/**
@ -56,4 +72,18 @@ class DefaultInactiveTabsInteractor(
override fun onEnabledAutoCloseClicked() {
controller.enableAutoClosed()
}
/**
* See [InactiveTabsInteractor.onTabClicked].
*/
override fun onTabClicked(tab: TabSessionState) {
controller.openInactiveTab(tab)
}
/**
* See [InactiveTabsInteractor.onTabClosed].
*/
override fun onTabClosed(tab: TabSessionState) {
controller.closeInactiveTab(tab)
}
}

@ -9,9 +9,7 @@ import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import mozilla.components.browser.tabstray.TabViewHolder
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
@ -23,10 +21,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
private val concatAdapter by lazy { adapter as ConcatAdapter }
private val inactiveTabsBinding by lazy {
InactiveTabsBinding(tabsTrayStore, concatAdapter.inactiveTabsAdapter)
}
private val normalTabsBinding by lazy {
NormalTabsBinding(tabsTrayStore, context.components.core.store, concatAdapter.browserAdapter)
}
@ -39,17 +33,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
TabGroupBinding(tabsTrayStore) { concatAdapter.tabGroupAdapter.submitList(it) }
}
private val inactiveTabsInteractor by lazy {
DefaultInactiveTabsInteractor(
InactiveTabsController(
tabsTrayStore,
context.components.appStore,
concatAdapter.inactiveTabsAdapter,
context.settings()
)
)
}
private val touchHelper by lazy {
TabsTouchHelper(
interactionDelegate = concatAdapter.browserAdapter.interactor,
@ -64,9 +47,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
concatAdapter.inactiveTabsAdapter.inactiveTabsInteractor = inactiveTabsInteractor
inactiveTabsBinding.start()
normalTabsBinding.start()
titleHeaderBinding.start()
tabGroupBinding.start()
@ -77,7 +57,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
inactiveTabsBinding.stop()
normalTabsBinding.stop()
titleHeaderBinding.stop()
tabGroupBinding.stop()

@ -43,11 +43,11 @@ fun BrowserState.getNormalTrayTabs(
): List<TabSessionState> {
val tabGroupsTabIds = getTabGroups()?.flatMap { it.tabIds } ?: emptyList()
return normalTabs.run {
if (searchTermTabGroupsAreEnabled && inactiveTabsEnabled) {
if (searchTermTabGroupsAreEnabled && tabGroupsTabIds.isNotEmpty() && inactiveTabsEnabled) {
filter { it.isNormalTabActive(maxActiveTime) }.filter { tabGroupsTabIds.contains(it.id) }
} else if (inactiveTabsEnabled) {
filter { it.isNormalTabActive(maxActiveTime) }
} else if (searchTermTabGroupsAreEnabled) {
} else if (searchTermTabGroupsAreEnabled && tabGroupsTabIds.isNotEmpty()) {
filter { it.isNormalTab() }.filter { tabGroupsTabIds.contains(it.id) }
} else {
this

@ -5,6 +5,7 @@
package org.mozilla.fenix.tabstray.ext
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
fun TabSessionState.isActive(maxActiveTime: Long): Boolean {
val lastActiveTime = maxOf(lastAccess, createdAt)
@ -53,3 +54,8 @@ internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean {
internal fun TabSessionState.isNormalTab(): Boolean {
return !content.private
}
/**
* Returns a [String] for displaying a [TabSessionState]'s title or its url when a title is not available.
*/
fun TabSessionState.toDisplayTitle(): String = content.title.ifEmpty { content.url.take(MAX_URI_LENGTH) }

@ -0,0 +1,278 @@
/* 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/. */
@file:Suppress("TooManyFunctions")
package org.mozilla.fenix.tabstray.inactivetabs
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.PrimaryText
import org.mozilla.fenix.compose.SecondaryText
import org.mozilla.fenix.compose.button.TextButton
import org.mozilla.fenix.compose.list.ExpandableListHeader
import org.mozilla.fenix.compose.list.FaviconListItem
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.tabstray.ext.toDisplayTitle
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp)
/**
* Top-level list for displaying an expandable section of Inactive Tabs.
*
* @param inactiveTabs List of [TabSessionState] to display.
* @param expanded Whether to show the inactive tabs section expanded or collapsed.
* @param showAutoCloseDialog Whether to show the auto close inactive tabs dialog.
* @param onHeaderClick Called when the user clicks on the inactive tabs section header.
* @param onDeleteAllButtonClick Called when the user clicks on the delete all inactive tabs button.
* @param onAutoCloseDismissClick Called when the user clicks on the auto close dialog's dismiss button.
* @param onEnableAutoCloseClick Called when the user clicks on the auto close dialog's enable button.
* @param onTabClick Called when the user clicks on a specific inactive tab.
* @param onTabCloseClick Called when the user clicks on a specific inactive tab's close button.
*/
@Composable
@Suppress("LongParameterList")
fun InactiveTabsList(
inactiveTabs: List<TabSessionState>,
expanded: Boolean,
showAutoCloseDialog: Boolean,
onHeaderClick: () -> Unit,
onDeleteAllButtonClick: () -> Unit,
onAutoCloseDismissClick: () -> Unit,
onEnableAutoCloseClick: () -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabCloseClick: (TabSessionState) -> Unit,
) {
Card(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
shape = ROUNDED_CORNER_SHAPE,
backgroundColor = FirefoxTheme.colors.layer2,
border = BorderStroke(
width = 1.dp,
color = FirefoxTheme.colors.borderPrimary,
),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
InactiveTabsHeader(
expanded = expanded,
onClick = onHeaderClick,
onDeleteAllClick = onDeleteAllButtonClick,
)
if (expanded) {
if (showAutoCloseDialog) {
InactiveTabsAutoClosePrompt(
onDismissClick = onAutoCloseDismissClick,
onEnableAutoCloseClick = onEnableAutoCloseClick,
)
}
Column {
inactiveTabs.forEach { tab ->
val tabUrl = tab.content.url.toShortUrl()
FaviconListItem(
label = tab.toDisplayTitle(),
description = tabUrl,
onClick = { onTabClick(tab) },
url = tabUrl,
iconPainter = painterResource(R.drawable.mozac_ic_close),
iconDescription = stringResource(R.string.content_description_close_button),
onIconClick = { onTabCloseClick(tab) },
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
/**
* Collapsible header for the Inactive Tabs section.
*
* @param expanded Whether the section is expanded.
* @param onClick Called when the user clicks on the header.
* @param onDeleteAllClick Called when the user clicks on the delete all button.
*/
@Composable
private fun InactiveTabsHeader(
expanded: Boolean,
onClick: () -> Unit,
onDeleteAllClick: () -> Unit,
) {
ExpandableListHeader(
headerText = stringResource(R.string.inactive_tabs_title),
expanded = expanded,
expandActionContentDescription = stringResource(R.string.inactive_tabs_expand_content_description),
collapseActionContentDescription = stringResource(R.string.inactive_tabs_collapse_content_description),
onClick = onClick,
) {
IconButton(
onClick = onDeleteAllClick,
modifier = Modifier.padding(horizontal = 4.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.inactive_tabs_delete_all),
tint = FirefoxTheme.colors.iconPrimary,
)
}
}
}
/**
* Inactive Tabs auto close dialog.
*
* @param onDismissClick Called when the user clicks on the auto close dialog's dismiss button.
* @param onEnableAutoCloseClick Called when the user clicks on the auto close dialog's enable button.
*/
@Composable
private fun InactiveTabsAutoClosePrompt(
onDismissClick: () -> Unit,
onEnableAutoCloseClick: () -> Unit,
) {
Card(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
shape = ROUNDED_CORNER_SHAPE,
backgroundColor = FirefoxTheme.colors.layer2,
border = BorderStroke(
width = 1.dp,
color = FirefoxTheme.colors.borderPrimary,
),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.End,
) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
PrimaryText(
text = stringResource(R.string.tab_tray_inactive_auto_close_title),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.metropolis_semibold)),
)
IconButton(
onClick = onDismissClick,
modifier = Modifier.size(20.dp)
) {
Icon(
painter = painterResource(R.drawable.mozac_ic_close_20),
contentDescription =
stringResource(R.string.tab_tray_inactive_auto_close_button_content_description),
tint = FirefoxTheme.colors.iconPrimary,
)
}
}
SecondaryText(
text = stringResource(
R.string.tab_tray_inactive_auto_close_body_2,
stringResource(R.string.app_name)
),
modifier = Modifier.fillMaxWidth(),
fontSize = 14.sp,
)
TextButton(
text = stringResource(R.string.tab_tray_inactive_turn_on_auto_close_button_2),
onClick = onEnableAutoCloseClick,
)
}
}
}
@Composable
@Preview(name = "Auto close dialog dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Auto close dialog light", uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun InactiveTabsAutoClosePromptPreview() {
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
InactiveTabsAutoClosePrompt(
onDismissClick = {},
onEnableAutoCloseClick = {},
)
}
}
}
@Composable
@Preview(name = "Full preview dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Full preview light", uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun InactiveTabsListPreview() {
var expanded by remember { mutableStateOf(true) }
var showAutoClosePrompt by remember { mutableStateOf(true) }
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
InactiveTabsList(
inactiveTabs = generateFakeInactiveTabsList(),
expanded = expanded,
showAutoCloseDialog = showAutoClosePrompt,
onHeaderClick = { expanded = !expanded },
onDeleteAllButtonClick = {},
onAutoCloseDismissClick = { showAutoClosePrompt = !showAutoClosePrompt },
onEnableAutoCloseClick = { showAutoClosePrompt = !showAutoClosePrompt },
onTabClick = {},
onTabCloseClick = {},
)
}
}
}
private fun generateFakeInactiveTabsList(): List<TabSessionState> =
listOf(
TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
),
TabSessionState(
id = "tabId",
content = ContentState(
url = "www.google.com",
)
),
)

@ -5,10 +5,9 @@
package org.mozilla.fenix.tabstray.viewholders
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.tabstray.TabsTrayInteractor
@ -72,11 +71,11 @@ abstract class AbstractBrowserPageViewHolder(
adapterRef?.let { adapter ->
adapterObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
updateTrayVisibility(adapter.itemCount)
updateTrayVisibility(showTrayList(adapter))
}
override fun onItemRangeRemoved(positionstart: Int, itemcount: Int) {
updateTrayVisibility(adapter.itemCount)
updateTrayVisibility(showTrayList(adapter))
}
}
adapterObserver?.let {
@ -97,14 +96,18 @@ abstract class AbstractBrowserPageViewHolder(
adapterObserver = null
}
}
/**
* A way for an implementor of [AbstractBrowserPageViewHolder] to define their own behavior of
* when to show/hide the tray list and empty list UI.
*/
open fun showTrayList(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>): Boolean =
adapter.itemCount > 0
private fun updateTrayVisibility(size: Int) {
if (size == 0) {
trayList.visibility = GONE
emptyList.visibility = VISIBLE
} else {
trayList.visibility = VISIBLE
emptyList.visibility = GONE
}
/**
* Helper function used to toggle the visibility of the tabs tray lists and the empty list message.
*/
fun updateTrayVisibility(showTrayList: Boolean) {
trayList.isVisible = showTrayList
emptyList.isVisible = !showTrayList
}
}

@ -6,44 +6,51 @@ package org.mozilla.fenix.tabstray.viewholders
import android.content.Context
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.selector.selectedNormalTab
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.ext.potentialInactiveTabs
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.containsTabId
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns
import org.mozilla.fenix.tabstray.ext.getNormalTrayTabs
import org.mozilla.fenix.ext.potentialInactiveTabs
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.tabstray.ext.isNormalTabInactive
import org.mozilla.fenix.tabstray.ext.observeFirstInsert
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
/**
* View holder for the normal tabs tray list.
*/
class NormalBrowserPageViewHolder(
containerView: View,
private val lifecycleOwner: LifecycleOwner,
private val tabsTrayStore: TabsTrayStore,
private val browserStore: BrowserStore,
private val appStore: AppStore,
interactor: TabsTrayInteractor,
) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder<TabSessionState> {
private var inactiveTabsSize = 0
/**
* Holds the list of selected tabs.
*
@ -66,6 +73,8 @@ class NormalBrowserPageViewHolder(
browserAdapter.selectionHolder = this
tabGroupAdapter.selectionHolder = this
observeTabsTrayInactiveTabsState(adapter)
super.bind(adapter, manager)
}
@ -98,11 +107,9 @@ class NormalBrowserPageViewHolder(
appStore.dispatch(AppAction.UpdateInactiveExpanded(true))
inactiveTabAdapter.observeFirstInsert {
inactiveTabsList.forEachIndexed { tabIndex, item ->
inactiveTabsList.forEach { item ->
if (item.id == selectedTab.id) {
// Inactive Tabs are first + inactive header item.
val indexToScrollTo = tabIndex + 1
layoutManager.scrollToPosition(indexToScrollTo)
containerView.post { layoutManager.scrollToPosition(0) }
return@observeFirstInsert
}
@ -132,7 +139,7 @@ class NormalBrowserPageViewHolder(
// Index is based on tabs above (inactive) with our calculated index.
val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex
layoutManager.scrollToPosition(indexToScrollTo)
containerView.post { layoutManager.scrollToPosition(indexToScrollTo) }
if (focusGroupTabId != null) {
tabsTrayStore.dispatch(TabsTrayAction.ConsumeFocusGroupTabId)
@ -158,7 +165,7 @@ class NormalBrowserPageViewHolder(
tabGroupAdapter.itemCount +
headerAdapter.itemCount + tabIndex
layoutManager.scrollToPosition(indexToScrollTo)
containerView.post { layoutManager.scrollToPosition(indexToScrollTo) }
return@observeFirstInsert
}
@ -167,6 +174,24 @@ class NormalBrowserPageViewHolder(
}
}
// Temporary hack until https://github.com/mozilla-mobile/fenix/issues/21901 where the
// logic that shows/hides the "Your open tabs will be shown here." message will no longer be derived
// from adapters, view holders, and item counts.
override fun showTrayList(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>): Boolean {
return inactiveTabsSize > 0 || adapter.itemCount > 1 // InactiveTabsAdapter will always return 1
}
private fun observeTabsTrayInactiveTabsState(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>) {
tabsTrayStore.flowScoped(lifecycleOwner) { flow ->
flow.map { state -> state.inactiveTabs }
.ifChanged()
.collect { inactiveTabs ->
inactiveTabsSize = inactiveTabs.size
updateTrayVisibility(showTrayList(adapter))
}
}
}
private fun setupLayoutManager(
context: Context,
concatAdapter: ConcatAdapter

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android" android:constantSize="true">
<item android:drawable="@drawable/rounded_top_corners" android:state_activated="true" />
<item android:drawable="@drawable/rounded_all_corners" />
</selector>

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="?borderPrimary" />
<corners android:radius="8dp" />
<solid android:color="?attr/layer2" />
</shape>

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/layer2" />
<corners android:bottomLeftRadius="@dimen/tab_corner_radius" android:bottomRightRadius="@dimen/tab_corner_radius" />
</shape>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/layer2" />
<corners android:topLeftRadius="@dimen/tab_corner_radius" android:topRightRadius="@dimen/tab_corner_radius" />
</shape>

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rounded_bottom_corners"
android:backgroundTint="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingEnd="1dp"
android:paddingBottom="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_bottom_corners"
android:paddingBottom="8dp">
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/inactive_header_border"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:background="@drawable/card_list_row_background"
android:backgroundTint="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingTop="1dp"
android:paddingEnd="1dp">
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/card_list_row_background"
android:clickable="false"
android:clipToPadding="false"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:paddingStart="16dp">
<TextView
android:id="@+id/inactive_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="start"
android:maxLines="1"
android:minLines="1"
android:text="@string/inactive_tabs_title"
android:textAppearance="@style/Header16TextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Inactive tabs" />
<ImageView
android:id="@+id/chevron"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:contentDescription="@string/tab_menu"
app:layout_constraintBottom_toBottomOf="@id/inactive_title"
app:layout_constraintStart_toEndOf="@id/inactive_title"
app:layout_constraintTop_toTopOf="@id/inactive_title"
app:srcCompat="@drawable/ic_chevron" />
<ImageView
android:id="@+id/delete"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:contentDescription="@string/inactive_tabs_delete_all"
android:foreground="?android:attr/selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingEnd="1dp">
<mozilla.components.ui.widgets.WidgetSiteItemView
android:id="@+id/site_list_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/layer2"
android:foreground="?android:attr/selectableItemBackground"
android:minHeight="@dimen/mozac_widget_site_item_height" />
</FrameLayout>

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:paddingHorizontal="1dp"
android:background="?borderPrimary">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/layer2"
android:clickable="false"
android:clipToPadding="false"
android:focusable="true"
android:padding="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/banner_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:clipToPadding="false"
android:background="@drawable/inactive_tab_auto_close_border_background"
android:focusable="true"
android:padding="12dp">
<TextView
android:id="@+id/banner_info_message"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:padding="8dp"
android:text="@string/tab_tray_inactive_auto_close_title"
android:textAppearance="@style/Header14TextStyle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/close_button"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/close_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_tray_inactive_auto_close_button_content_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_close" />
<TextView
android:id="@+id/message"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:padding="8dp"
android:text="@string/tab_tray_inactive_auto_close_body_2"
android:textAppearance="@style/Body14TextStyle"
app:layout_constraintTop_toBottomOf="@id/banner_info_message"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/action"
style="@style/DialogButtonStyleDark"
android:background="?android:attr/selectableItemBackground"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="3dp"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/tab_tray_inactive_turn_on_auto_close_button_2"
app:layout_constraintTop_toBottomOf="@+id/message" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</FrameLayout>

@ -700,7 +700,7 @@
<!-- Open tabs menu item to save tabs to collection -->
<string name="tabs_menu_save_to_collection1">Save tabs to collection</string>
<!-- Content description (not visible, for screen readers etc.): Opens the tab menu when pressed -->
<string name="tab_menu">Tab menu</string>
<string name="tab_menu" moz:RemovedIn="103" tools:ignore="UnusedResources">Tab menu</string>
<!-- Text for the menu button to delete a collection -->
<string name="collection_delete">Delete collection</string>
<!-- Text for the menu button to rename a collection -->
@ -1739,6 +1739,10 @@
<string name="inactive_tabs_title">Inactive tabs</string>
<!-- Content description for closing all inactive tabs -->
<string name="inactive_tabs_delete_all">Close all inactive tabs</string>
<!-- Content description for expanding the inactive tabs section. -->
<string name="inactive_tabs_expand_content_description">Expand inactive tabs</string>
<!-- Content description for collapsing the inactive tabs section. -->
<string name="inactive_tabs_collapse_content_description">Collapse inactive tabs</string>
<!-- Inactive tabs auto-close message in the tabs tray -->
<!-- The header text of the auto-close message when the user is asked if they want to turn on the auto-closing of inactive tabs. -->

@ -213,6 +213,7 @@ class TabsTrayFragmentTest {
@Test
fun `WHEN setupPager is called THEN it sets the tray adapter and disables user initiated scrolling`() {
val store: TabsTrayStore = mockk()
val lifecycleOwner = mockk<LifecycleOwner>(relaxed = true)
val trayInteractor: TabsTrayInteractor = mockk()
val browserInteractor: BrowserTrayInteractor = mockk()
val navigationInteractor: NavigationInteractor = mockk()
@ -220,13 +221,19 @@ class TabsTrayFragmentTest {
every { context.components.core.store } returns browserStore
fragment.setupPager(
context, store, trayInteractor, browserInteractor, navigationInteractor
context = context,
lifecycleOwner = lifecycleOwner,
store = store,
trayInteractor = trayInteractor,
browserInteractor = browserInteractor,
navigationInteractor = navigationInteractor,
)
val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter)
assertSame(context, adapter.context)
assertSame(lifecycleOwner, adapter.lifecycleOwner)
assertSame(store, adapter.tabsTrayStore)
assertSame(trayInteractor, adapter.interactor)
assertSame(trayInteractor, adapter.tabsTrayInteractor)
assertSame(browserInteractor, adapter.browserInteractor)
assertSame(navigationInteractor, adapter.navInteractor)
assertSame(browserStore, adapter.browserStore)

@ -6,6 +6,8 @@ package org.mozilla.fenix.tabstray.browser
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState
import org.junit.Test
class DefaultInactiveTabsInteractorTest {
@ -39,4 +41,36 @@ class DefaultInactiveTabsInteractorTest {
verify { controller.enableAutoClosed() }
}
@Test
fun `WHEN an inactive tab is clicked THEN open the tab`() {
val controller: InactiveTabsController = mockk(relaxed = true)
val interactor = DefaultInactiveTabsInteractor(controller)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
interactor.onTabClicked(tab)
verify { controller.openInactiveTab(tab) }
}
@Test
fun `WHEN an inactive tab is clicked to be closed THEN close the tab`() {
val controller: InactiveTabsController = mockk(relaxed = true)
val interactor = DefaultInactiveTabsInteractor(controller)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
interactor.onTabClosed(tab)
verify { controller.closeInactiveTab(tab) }
}
}

@ -1,39 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.tabstray.browser
import android.view.LayoutInflater
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder
@RunWith(FenixRobolectricTestRunner::class)
class InactiveTabViewHolderTest {
@Test
fun `HeaderHolder - WHEN clicked THEN notify the interactor`() {
every { testContext.components.appStore } returns AppStore()
val view = LayoutInflater.from(testContext).inflate(HeaderHolder.LAYOUT_ID, null)
val interactor: InactiveTabsInteractor = mockk(relaxed = true)
val tabsTrayInteractor: TabsTrayInteractor = mockk(relaxed = true)
val viewHolder = HeaderHolder(view, interactor, tabsTrayInteractor)
val initialActivatedState = view.isActivated
viewHolder.itemView.performClick()
verify { interactor.onHeaderClicked(any()) }
assertEquals(!initialActivatedState, view.isActivated)
}
}

@ -1,58 +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 io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayStore
class InactiveTabsBindingTest {
val store = TabsTrayStore()
val tray: TabsTray = mockk(relaxed = true)
val binding = InactiveTabsBinding(store, tray)
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@After
fun teardown() {
binding.stop()
}
@Test
fun `WHEN the store is updated THEN notify the tabs tray`() {
assertTrue(store.state.inactiveTabs.isEmpty())
store.dispatch(TabsTrayAction.UpdateInactiveTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
binding.start()
assertTrue(store.state.inactiveTabs.isNotEmpty())
verify { tray.updateTabs(any(), any(), any()) }
}
@Test
fun `WHEN non-inactive tabs are updated THEN do not notify the tabs tray`() {
assertTrue(store.state.inactiveTabs.isEmpty())
store.dispatch(TabsTrayAction.UpdatePrivateTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
binding.start()
assertTrue(store.state.inactiveTabs.isEmpty())
verify { tray.updateTabs(emptyList(), null, null) }
}
}

@ -4,73 +4,37 @@
package org.mozilla.fenix.tabstray.browser
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.service.glean.testing.GleanTestRule
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import mozilla.components.browser.state.state.createTab as createTabState
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TrayPagerAdapter
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
@RunWith(FenixRobolectricTestRunner::class)
class InactiveTabsControllerTest {
private val settings: Settings = mockk(relaxed = true)
private val browserInteractor: BrowserTrayInteractor = mockk(relaxed = true)
private val appStore = AppStore()
@get:Rule
val gleanTestRule = GleanTestRule(testContext)
@Test
fun `WHEN expanded THEN notify filtered card`() {
val store = TabsTrayStore(
TabsTrayState(
inactiveTabs = listOf(
createTabState("https://mozilla.org", id = "1"),
createTabState("https://firefox.com", id = "2")
)
)
)
val tray: TabsTray = mockk(relaxed = true)
val tabsSlot = slot<List<TabSessionState>>()
val controller =
InactiveTabsController(store, appStore, tray, settings)
controller.updateCardExpansion(true)
appStore.waitUntilIdle()
verify { tray.updateTabs(capture(tabsSlot), null, any()) }
assertEquals(2, tabsSlot.captured.size)
assertEquals("1", tabsSlot.captured.first().id)
}
@Test
fun `WHEN expanded THEN track telemetry event`() {
val store = TabsTrayStore()
val controller = InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
fun `WHEN the inactive tabs section is expanded THEN the expanded telemetry event should be report`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue())
assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue())
@ -82,11 +46,8 @@ class InactiveTabsControllerTest {
}
@Test
fun `WHEN collapsed THEN track telemetry event`() {
val store = TabsTrayStore()
val controller = InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
fun `WHEN the inactive tabs section is collapsed THEN the collapsed telemetry event should be report`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue())
assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue())
@ -98,15 +59,8 @@ class InactiveTabsControllerTest {
}
@Test
fun `WHEN close THEN update settings and refresh`() {
val store = TabsTrayStore()
val controller = spyk(
InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
)
every { controller.refreshInactiveTabsSection() } just Runs
fun `WHEN the inactive tabs auto-close feature prompt is dismissed THEN update settings and report the telemetry event`() {
val controller = spyk(InactiveTabsController(appStore, settings, browserInteractor))
assertFalse(TabsTrayMetrics.autoCloseDimissed.testHasValue())
@ -114,29 +68,59 @@ class InactiveTabsControllerTest {
assertTrue(TabsTrayMetrics.autoCloseDimissed.testHasValue())
verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
verify { controller.refreshInactiveTabsSection() }
}
@Test
fun `WHEN enableAutoClosed THEN update closeTabsAfterOneMonth settings and refresh`() {
val filter: (TabSessionState) -> Boolean = { !it.content.private }
val store = BrowserStore()
val tray: TabsTray = mockk(relaxed = true)
val controller =
spyk(InactiveTabsAutoCloseDialogController(store, settings, filter, tray))
every { controller.refreshInactiveTabsSection() } just Runs
fun `WHEN the inactive tabs auto-close feature prompt is accepted THEN update settings and report the telemetry event`() {
val controller = spyk(InactiveTabsController(appStore, settings, browserInteractor))
assertFalse(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue())
controller.enableAutoClosed()
assertTrue(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue())
verify { settings.closeTabsAfterOneMonth = true }
verify { settings.closeTabsAfterOneWeek = false }
verify { settings.closeTabsAfterOneDay = false }
verify { settings.manuallyCloseTabs = false }
verify { controller.refreshInactiveTabsSection() }
verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
}
@Test
fun `WHEN an inactive tab is selected THEN the open the tab and report the telemetry event`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
assertFalse(TabsTrayMetrics.openInactiveTab.testHasValue())
controller.openInactiveTab(tab)
verify { browserInteractor.onTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
assertTrue(TabsTrayMetrics.openInactiveTab.testHasValue())
}
@Test
fun `WHEN an inactive tab is closed THEN the close the tab and report the telemetry event`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
assertFalse(TabsTrayMetrics.openInactiveTab.testHasValue())
controller.openInactiveTab(tab)
verify { browserInteractor.onTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
assertTrue(TabsTrayMetrics.openInactiveTab.testHasValue())
}
}

Loading…
Cancel
Save