Issue #19112: Remove old tab tray code

upstream-sync
Jonathan Almeida 3 years ago committed by Jonathan Almeida
parent bf605c02d9
commit dc11c334b6

@ -251,18 +251,10 @@ class SmokeTest {
// Verifies the Synced tabs menu or Sync Sign In menu opens from a tab's 3 dot menu.
// The test is assuming we are NOT signed in.
fun openMainMenuSyncItemTest() {
if (FeatureFlags.tabsTrayRewrite) {
homeScreen {
}.openThreeDotMenu {
}.openSyncSignIn {
verifySyncSignInMenuHeader()
}
} else {
homeScreen {
}.openThreeDotMenu {
}.openSyncedTabs {
verifySyncedTabsMenuHeader()
}
homeScreen {
}.openThreeDotMenu {
}.openSyncSignIn {
verifySyncSignInMenuHeader()
}
}
@ -888,6 +880,7 @@ class SmokeTest {
}
}
@Ignore("Enable after #19738 and #19090 land.")
@Test
fun createFirstCollectionTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)

@ -49,11 +49,7 @@ class ThreeDotMenuMainTest {
verifyHistoryButton()
verifyDownloadsButton()
verifyAddOnsButton()
if (FeatureFlags.tabsTrayRewrite) {
verifySyncSignInButton()
} else {
verifySyncedTabsButton()
}
verifySyncSignInButton()
verifyDesktopSite()
verifyWhatsNewButton()
verifyHelpButton()

@ -1,46 +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.ui.robots
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.espresso.matcher.ViewMatchers.Visibility
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.hamcrest.CoreMatchers.allOf
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.click
/**
* Implementation of Robot Pattern for Synced Tabs sub menu.
*/
class SyncedTabsRobot {
fun verifySyncedTabsMenuHeader() = assertSyncedTabsMenuHeader()
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())!!
fun goBack(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
goBackButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
}
}
}
private fun goBackButton() =
onView(allOf(withContentDescription("Navigate up")))
private fun assertSyncedTabsMenuHeader() {
// Replaced with the new string here, the test is assuming we are NOT signed in
// Sync tests in SettingsSyncTest are still TO-DO, so I'm not sure that we have a test for signing into Sync
onView(withText(R.string.sync_menu_sign_in))
.check((matches(withEffectiveVisibility(Visibility.VISIBLE))))
}

@ -51,7 +51,6 @@ class ThreeDotMenuMainRobot {
fun verifyAddOnsButton() = assertAddOnsButton()
fun verifyHistoryButton() = assertHistoryButton()
fun verifyBookmarksButton() = assertBookmarksButton()
fun verifySyncedTabsButton() = assertSyncedTabsButton()
fun verifySyncSignInButton() = assertSignInToSyncButton()
fun verifyHelpButton() = assertHelpButton()
fun verifyThreeDotMenuExists() = threeDotMenuRecyclerViewExists()
@ -157,15 +156,6 @@ class ThreeDotMenuMainRobot {
return DownloadRobot.Transition()
}
fun openSyncedTabs(interact: SyncedTabsRobot.() -> Unit): SyncedTabsRobot.Transition {
onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown())
mDevice.waitNotNull(Until.findObject(By.text("Synced tabs")), waitingTime)
syncedTabsButton().click()
SyncedTabsRobot().interact()
return SyncedTabsRobot.Transition()
}
fun openSyncSignIn(interact: SyncSignInRobot.() -> Unit): SyncSignInRobot.Transition {
onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown())
mDevice.waitNotNull(Until.findObject(By.text("Sign in to sync")), waitingTime)
@ -418,10 +408,6 @@ private fun bookmarksButton() = onView(allOf(withText(R.string.library_bookmarks
private fun assertBookmarksButton() = bookmarksButton()
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
private fun syncedTabsButton() = onView(allOf(withText(R.string.library_synced_tabs)))
private fun assertSyncedTabsButton() = syncedTabsButton()
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
private fun signInToSyncButton() = onView(withText("Sign in to sync"))
private fun assertSignInToSyncButton() = signInToSyncButton().check(matches(isDisplayed()))

@ -31,7 +31,6 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromAddonDetailsFragment(R.id.addonDetailsFragment),
FromAddonPermissionsDetailsFragment(R.id.addonPermissionsDetailFragment),
FromLoginDetailFragment(R.id.loginDetailFragment),
FromTabTrayDialog(R.id.tabTrayDialogFragment),
FromTabTray(R.id.tabsTrayFragment),
FromTabsTray(R.id.tabsTrayFragment),
FromRecentlyClosed(R.id.recentlyClosedFragment)
}

@ -28,9 +28,4 @@ object FeatureFlags {
* Enables WebAuthn support.
*/
val webAuthFeature = Config.channel.isNightlyOrDebug
/**
* Enables the tabs tray re-write with Synced Tabs.
*/
const val tabsTrayRewrite = true
}

@ -112,9 +112,8 @@ import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections
import org.mozilla.fenix.sync.SyncedTabsFragmentDirections
import org.mozilla.fenix.tabstray.TabsTrayFragment
import org.mozilla.fenix.tabstray.TabsTrayFragmentDirections
import org.mozilla.fenix.tabtray.TabTrayDialogFragment
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentDirections
import org.mozilla.fenix.theme.DefaultThemeManager
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.BrowsersCache
@ -502,7 +501,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
?.childFragmentManager
?.fragments
?.lastOrNull()
?.let { it as? TabTrayDialogFragment }
?.let { it as? TabsTrayFragment }
?.also { it.dismissAllowingStateLoss() }
}
@ -813,9 +812,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
AddonPermissionsDetailsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromLoginDetailFragment ->
LoginDetailFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromTabTrayDialog ->
TabTrayDialogFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromTabTray ->
BrowserDirection.FromTabsTray ->
TabsTrayFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromRecentlyClosed ->
RecentlyClosedFragmentDirections.actionGlobalBrowser(customTabSessionId)

@ -309,7 +309,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
thumbnailsFeature.get()?.requestScreenshot()
findNavController().nav(
R.id.browserFragment,
getTrayDirection(context)
BrowserFragmentDirections.actionGlobalTabsTrayFragment()
)
},
onCloseTab = { closedSession ->
@ -1379,17 +1379,6 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
.show()
}
/**
* Retrieves the correct tray direction while using a feature flag.
*
* Remove this when [FeatureFlags.tabsTrayRewrite] is removed.
*/
private fun getTrayDirection(context: Context) = if (context.settings().tabsTrayRewrite) {
BrowserFragmentDirections.actionGlobalTabsTrayFragment()
} else {
BrowserFragmentDirections.actionGlobalTabTrayDialogFragment()
}
companion object {
private const val KEY_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id"
private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1

@ -34,7 +34,6 @@ import mozilla.components.feature.webcompat.reporter.WebCompatReporterFeature
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
import org.mozilla.fenix.FeatureFlags.tabsTrayRewrite
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.ExperimentBranch
@ -371,7 +370,7 @@ open class DefaultToolbarMenu(
historyItem,
downloadsItem,
extensionsItem,
if (tabsTrayRewrite) syncMenuItem else syncedTabsItem,
syncMenuItem,
BrowserMenuDivider(),
getSetDefaultBrowserItem(),
getSetDefaultBrowserItem()?.let { BrowserMenuDivider() },

@ -262,7 +262,7 @@ class HomeFragment : Fragment() {
hideOnboarding = ::hideOnboardingAndOpenSearch,
registerCollectionStorageObserver = ::registerCollectionStorageObserver,
showDeleteCollectionPrompt = ::showDeleteCollectionPrompt,
showTabTray = ::openTabTray,
showTabTray = ::openTabsTray,
handleSwipedItemDeletionCancel = ::handleSwipedItemDeletionCancel
)
)
@ -396,7 +396,7 @@ class HomeFragment : Fragment() {
}
view.tab_button.setOnClickListener {
openTabTray()
openTabsTray()
}
PrivateBrowsingButtonView(
@ -1038,15 +1038,10 @@ class HomeFragment : Fragment() {
}
}
private fun openTabTray() {
val direction = if (requireContext().settings().tabsTrayRewrite) {
HomeFragmentDirections.actionGlobalTabsTrayFragment()
} else {
HomeFragmentDirections.actionGlobalTabTrayDialogFragment()
}
private fun openTabsTray() {
findNavController().nav(
R.id.homeFragment,
direction
HomeFragmentDirections.actionGlobalTabsTrayFragment()
)
}

@ -23,7 +23,6 @@ import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.FeatureFlags.tabsTrayRewrite
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.FeatureId
@ -209,7 +208,7 @@ class HomeMenu(
historyItem,
downloadsItem,
extensionsItem,
if (tabsTrayRewrite) syncSignInMenuItem else syncedTabsItem,
syncSignInMenuItem,
accountAuthItem,
BrowserMenuDivider(),
desktopItem,

@ -484,7 +484,7 @@ class DefaultSessionControlController(
}
private fun showTabTrayCollectionCreation() {
val directions = HomeFragmentDirections.actionGlobalTabTrayDialogFragment(
val directions = HomeFragmentDirections.actionGlobalTabsTrayFragment(
enterMultiselect = true
)
navController.nav(R.id.homeFragment, directions)

@ -30,12 +30,6 @@ class SecretSettingsFragment : PreferenceFragmentCompat() {
onPreferenceChangeListener = SharedPreferenceUpdater()
}
requirePreference<SwitchPreference>(R.string.pref_key_new_tabs_tray).apply {
isVisible = FeatureFlags.tabsTrayRewrite
isChecked = context.settings().tabsTrayRewrite
onPreferenceChangeListener = SharedPreferenceUpdater()
}
requirePreference<SwitchPreference>(R.string.pref_key_allow_third_party_root_certs).apply {
isVisible = true
isChecked = context.settings().allowThirdPartyRootCerts

@ -209,7 +209,7 @@ class DefaultNavigationInteractor(
activity.openToBrowserAndLoad(
searchTermOrURL = tab.active().url,
newTab = true,
from = BrowserDirection.FromTabTray
from = BrowserDirection.FromTabsTray
)
}
}

@ -20,7 +20,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentDirections
interface TabsTrayController {
@ -77,7 +76,7 @@ class DefaultTabsTrayController(
val startTime = profiler?.getProfilerTime()
browsingModeManager.mode = BrowsingMode.fromBoolean(isPrivate)
navController.navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true))
TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true))
navigationInteractor.onTabTrayDismissed()
profiler?.addMarker(
"DefaultTabTrayController.onNewTabTapped",

@ -53,7 +53,6 @@ import kotlin.math.max
@Suppress("TooManyFunctions", "LargeClass")
class TabsTrayFragment : AppCompatDialogFragment() {
private var fabView: View? = null
@VisibleForTesting internal lateinit var tabsTrayStore: TabsTrayStore
private lateinit var browserTrayInteractor: BrowserTrayInteractor

@ -1,158 +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.tabtray
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.checkbox_item.view.*
import kotlinx.android.synthetic.main.tab_tray_item.view.*
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.browser.tabstray.TabsAdapter
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.Tabs
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.updateAccessibilityCollectionItemInfo
class FenixTabsAdapter(
private val context: Context,
imageLoader: ImageLoader = ThumbnailLoader(context.components.core.thumbnailStorage)
) : TabsAdapter(
viewHolderProvider = { parentView ->
TabTrayViewHolder(
LayoutInflater.from(context).inflate(
if (context.settings().gridTabView) R.layout.tab_tray_grid_item else R.layout.tab_tray_item,
parentView,
false
),
imageLoader
)
}
) {
private lateinit var tabsList: RecyclerView
var tabTrayInteractor: TabTrayInteractor? = null
private val mode: TabTrayDialogFragmentState.Mode?
get() = tabTrayInteractor?.onModeRequested()
val selectedItems get() = mode?.selectedItems ?: setOf()
var onTabsUpdated: (() -> Unit)? = null
var tabCount = 0
override fun updateTabs(tabs: Tabs) {
super.updateTabs(tabs)
onTabsUpdated?.invoke()
tabCount = tabs.list.size
}
override fun onBindViewHolder(
holder: TabViewHolder,
position: Int,
payloads: List<Any>
) {
if (payloads.isNullOrEmpty()) {
onBindViewHolder(holder, position)
return
}
// Having non-empty payloads means we have to make a partial update.
// This currently only happens when changing between the Normal and MultiSelect modes
// when we want to either show the last opened tab as selected (default) or hide this ui decorator.
if (mode is TabTrayDialogFragmentState.Mode.Normal) {
super.onBindViewHolder(holder, position, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
} else {
super.onBindViewHolder(holder, position, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
}
holder.tab?.let { showCheckedIfSelected(it, holder.itemView) }
}
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
val isListTabView = context.settings().listTabView
val itemIndex: Int
val rowIndex: Int
val columnIndex: Int
if (isListTabView) {
itemIndex = tabCount - position - 1
rowIndex = itemIndex
columnIndex = 1
} else {
val columnsCount = (tabsList.layoutManager as GridLayoutManager).spanCount
itemIndex = position
rowIndex = itemIndex / columnsCount
columnIndex = itemIndex % columnsCount
}
holder.itemView.updateAccessibilityCollectionItemInfo(
rowIndex,
columnIndex,
selectedItems.contains(holder.tab)
)
holder.tab?.let { tab ->
showCheckedIfSelected(tab, holder.itemView)
val tabIsPrivate =
context.components.core.store.state.findTab(tab.id)?.content?.private == true
if (!tabIsPrivate) {
holder.itemView.setOnLongClickListener {
if (mode is TabTrayDialogFragmentState.Mode.Normal) {
context.metrics.track(Event.CollectionTabLongPressed)
tabTrayInteractor?.onAddSelectedTab(
tab
)
}
true
}
} else {
holder.itemView.setOnLongClickListener(null)
}
holder.itemView.setOnClickListener {
if (mode is TabTrayDialogFragmentState.Mode.MultiSelect) {
if (mode?.selectedItems?.contains(tab) == true) {
tabTrayInteractor?.onRemoveSelectedTab(tab = tab)
} else {
tabTrayInteractor?.onAddSelectedTab(tab = tab)
}
} else {
tabTrayInteractor?.onOpenTab(tab = tab)
}
}
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
tabsList = recyclerView
}
override fun isTabSelected(tabs: Tabs, position: Int): Boolean {
return mode is TabTrayDialogFragmentState.Mode.Normal &&
tabs.selectedIndex == position
}
private fun showCheckedIfSelected(tab: Tab, view: View) {
val shouldBeChecked =
mode is TabTrayDialogFragmentState.Mode.MultiSelect && selectedItems.contains(tab)
view.selected_mask.isVisible = shouldBeChecked
view.mozac_browser_tabstray_close.isVisible = mode is TabTrayDialogFragmentState.Mode.Normal
}
}

@ -1,40 +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.tabtray
import android.content.Context
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import org.mozilla.fenix.R
class MultiselectSelectionMenu(
private val context: Context,
private val onItemTapped: (Item) -> Unit = {}
) {
sealed class Item {
object BookmarkTabs : Item()
object DeleteTabs : Item()
}
val menuBuilder by lazy { BrowserMenuBuilder(menuItems) }
private val menuItems by lazy {
listOf(
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_multiselect_menu_item_bookmark),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.BookmarkTabs)
},
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_multiselect_menu_item_close),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.DeleteTabs)
}
)
}
}

@ -1,108 +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.tabtray
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.setNewAccessibilityParent
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.Item
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.ViewHolder
/**
* An adapter to display a single 'Save to Collections' button that can be used to display between
* multiple [RecyclerView.Adapter] in one [RecyclerView].
*/
class SaveToCollectionsButtonAdapter(
private val interactor: TabTrayInteractor,
private val isPrivate: () -> Boolean = { false }
) : ListAdapter<Item, ViewHolder>(DiffCallback) {
init {
submitList(listOf(Item))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return ViewHolder(itemView, interactor)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
// remove button from node info of tabs list for a11y services,and add it to the tab tray node
holder.itemView.accessibilityDelegate = object : View.AccessibilityDelegate() {
override fun onInitializeAccessibilityNodeInfo(
host: View?,
info: AccessibilityNodeInfo?
) {
super.onInitializeAccessibilityNodeInfo(host, info)
info?.collectionItemInfo = null
(holder.itemView.parentForAccessibility.parentForAccessibility as? View)?.let {
holder.itemView.setNewAccessibilityParent(it)
}
}
}
if (payloads.isNullOrEmpty()) {
onBindViewHolder(holder, position)
return
}
when (val change = payloads[0]) {
is TabTrayView.TabChange -> {
holder.itemView.isVisible = change == TabTrayView.TabChange.NORMAL
}
is MultiselectModeChange -> {
holder.itemView.isVisible = change == MultiselectModeChange.NORMAL
}
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.itemView.isVisible = !isPrivate() &&
interactor.onModeRequested() is TabTrayDialogFragmentState.Mode.Normal
}
override fun getItemViewType(position: Int): Int {
return ViewHolder.LAYOUT_ID
}
private object DiffCallback : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item) = true
override fun areContentsTheSame(oldItem: Item, newItem: Item) = true
}
enum class MultiselectModeChange {
MULTISELECT, NORMAL
}
/**
* An object to identify the data type.
*/
object Item
class ViewHolder(
itemView: View,
private val interactor: TabTrayInteractor
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
interactor.onEnterMultiselect()
}
companion object {
const val LAYOUT_ID = R.layout.tabs_tray_save_to_collections_item
}
}
}

@ -1,251 +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.tabtray
import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.base.profiler.Profiler
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.BookmarksStorage
import mozilla.components.concept.tabstray.Tab
import mozilla.components.feature.tabs.TabsUseCases
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.home.HomeFragment
/**
* [TabTrayDialogFragment] controller.
*
* Delegated by View Interactors, handles container business logic and operates changes on it.
*/
@Suppress("TooManyFunctions")
interface TabTrayController {
fun handleNewTabTapped(private: Boolean)
fun handleTabTrayDismissed()
fun handleTabSettingsClicked()
fun handleShareTabsOfTypeClicked(private: Boolean)
fun handleShareSelectedTabsClicked(selectedTabs: Set<Tab>)
fun handleSaveToCollectionClicked(selectedTabs: Set<Tab>)
fun handleBookmarkSelectedTabs(selectedTabs: Set<Tab>)
fun handleDeleteSelectedTabs(selectedTabs: Set<Tab>)
fun handleCloseAllTabsClicked(private: Boolean)
fun handleBackPressed(): Boolean
fun onModeRequested(): TabTrayDialogFragmentState.Mode
fun handleAddSelectedTab(tab: Tab)
fun handleRemoveSelectedTab(tab: Tab)
fun handleOpenTab(tab: Tab)
fun handleEnterMultiselect()
fun handleRecentlyClosedClicked()
fun handleGoToTabsSettingClicked()
}
/**
* Default behavior of [TabTrayController]. Other implementations are possible.
*
* @param activity [Activity] the current activity.
* @param profiler [Profiler] used for profiling.
* @param browserStore [BrowserStore] holds the global [BrowserState].
* @param browsingModeManager [HomeActivity] used for registering browsing mode.
* @param tabCollectionStorage [TabCollectionStorage] storage for saving collections.
* @param ioScope [CoroutineScope] with an IO dispatcher used for structured concurrency.
* @param metrics reference to the configured [MetricController] to record telemetry events.
* @param tabsUseCases [TabsUseCases] use cases related to the tabs feature.
* @param navController - [NavController] used for navigation.
* @param dismissTabTray callback allowing to request this entire Fragment to be dismissed.
* @param dismissTabTrayAndNavigateHome callback allowing showing an undo snackbar after tab deletion.
* @param registerCollectionStorageObserver callback allowing for registering the [TabCollectionStorage.Observer]
* when needed.
* @param tabTrayDialogFragmentStore [TabTrayDialogFragmentStore] holding the State for all Views displayed
* in this Controller's Fragment.
* @param selectTabUseCase [TabsUseCases.SelectTabUseCase] callback allowing for selecting a tab.
* @param showChooseCollectionDialog callback allowing saving a list of sessions to an existing collection.
* @param showAddNewCollectionDialog callback allowing for saving a list of sessions to a new collection.
* @param showUndoSnackbarForTabs callback allowing for showing an undo snackbar for removed tabs.
* @param showBookmarksSnackbar callback allowing for showing a snackbar with action to view bookmarks.
*/
@Suppress("TooManyFunctions")
class DefaultTabTrayController(
private val activity: HomeActivity,
private val profiler: Profiler?,
private val browserStore: BrowserStore,
private val browsingModeManager: BrowsingModeManager,
private val tabCollectionStorage: TabCollectionStorage,
private val bookmarksStorage: BookmarksStorage,
private val ioScope: CoroutineScope,
private val metrics: MetricController,
private val tabsUseCases: TabsUseCases,
private val navController: NavController,
private val dismissTabTray: () -> Unit,
private val dismissTabTrayAndNavigateHome: (String) -> Unit,
private val registerCollectionStorageObserver: () -> Unit,
private val tabTrayDialogFragmentStore: TabTrayDialogFragmentStore,
private val selectTabUseCase: TabsUseCases.SelectTabUseCase,
private val showChooseCollectionDialog: (List<TabSessionState>) -> Unit,
private val showAddNewCollectionDialog: (List<TabSessionState>) -> Unit,
private val showUndoSnackbarForTabs: () -> Unit,
private val showBookmarksSnackbar: () -> Unit
) : TabTrayController {
override fun handleNewTabTapped(private: Boolean) {
val startTime = profiler?.getProfilerTime()
browsingModeManager.mode = BrowsingMode.fromBoolean(private)
navController.navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true))
dismissTabTray()
profiler?.addMarker(
"DefaultTabTrayController.onNewTabTapped",
startTime
)
}
override fun handleTabSettingsClicked() {
navController.navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalTabSettingsFragment())
}
override fun handleTabTrayDismissed() {
dismissTabTray()
}
override fun handleSaveToCollectionClicked(selectedTabs: Set<Tab>) {
metrics.track(Event.TabsTraySaveToCollectionPressed)
val sessionList = selectedTabs.map {
browserStore.state.findTab(it.id) ?: return
}
// Only register the observer right before moving to collection creation
registerCollectionStorageObserver()
when {
tabCollectionStorage.cachedTabCollections.isNotEmpty() -> {
showChooseCollectionDialog(sessionList)
}
else -> {
showAddNewCollectionDialog(sessionList)
}
}
}
override fun handleShareTabsOfTypeClicked(private: Boolean) {
val tabs = browserStore.state.getNormalOrPrivateTabs(private)
val data = tabs.map {
ShareData(url = it.content.url, title = it.content.title)
}
val directions = TabTrayDialogFragmentDirections.actionGlobalShareFragment(
data = data.toTypedArray()
)
navController.navigateBlockingForAsyncNavGraph(directions)
}
override fun handleShareSelectedTabsClicked(selectedTabs: Set<Tab>) {
val data = selectedTabs.map {
ShareData(url = it.url, title = it.title)
}
val directions = TabTrayDialogFragmentDirections.actionGlobalShareFragment(
data = data.toTypedArray()
)
navController.navigateBlockingForAsyncNavGraph(directions)
}
override fun handleBookmarkSelectedTabs(selectedTabs: Set<Tab>) {
selectedTabs.forEach {
ioScope.launch {
val shouldAddBookmark = bookmarksStorage.getBookmarksWithUrl(it.url)
.firstOrNull { it.url == it.url } == null
if (shouldAddBookmark) {
bookmarksStorage.addItem(
BookmarkRoot.Mobile.id,
url = it.url,
title = it.title,
position = null
)
}
}
}
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
showBookmarksSnackbar()
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun handleDeleteSelectedTabs(selectedTabs: Set<Tab>) {
if (browserStore.state.normalTabs.size == selectedTabs.size) {
dismissTabTrayAndNavigateHome(HomeFragment.ALL_NORMAL_TABS)
} else {
selectedTabs.map { it.id }.let {
tabsUseCases.removeTabs(it)
}
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
showUndoSnackbarForTabs()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun handleCloseAllTabsClicked(private: Boolean) {
val sessionsToClose = if (private) {
HomeFragment.ALL_PRIVATE_TABS
} else {
HomeFragment.ALL_NORMAL_TABS
}
dismissTabTrayAndNavigateHome(sessionsToClose)
}
override fun handleAddSelectedTab(tab: Tab) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.AddItemForCollection(tab))
}
override fun handleRemoveSelectedTab(tab: Tab) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.RemoveItemForCollection(tab))
}
override fun handleBackPressed(): Boolean {
return if (tabTrayDialogFragmentStore.state.mode is TabTrayDialogFragmentState.Mode.MultiSelect) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
true
} else {
false
}
}
override fun onModeRequested(): TabTrayDialogFragmentState.Mode {
return tabTrayDialogFragmentStore.state.mode
}
override fun handleOpenTab(tab: Tab) {
selectTabUseCase.invoke(tab.id)
}
override fun handleEnterMultiselect() {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.EnterMultiSelectMode)
}
override fun handleRecentlyClosedClicked() {
val directions = TabTrayDialogFragmentDirections.actionGlobalRecentlyClosed()
navController.navigateBlockingForAsyncNavGraph(directions)
metrics.track(Event.RecentlyClosedTabsOpened)
}
override fun handleGoToTabsSettingClicked() {
val directions = TabTrayDialogFragmentDirections.actionGlobalTabSettingsFragment()
navController.navigateBlockingForAsyncNavGraph(directions)
metrics.track(Event.TabsTrayCfrTapped)
}
}

@ -1,515 +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.tabtray
import android.app.Dialog
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.component_tabstray.view.*
import kotlinx.android.synthetic.main.component_tabstray_fab.view.*
import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.*
import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.view.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.tabs.tabstray.TabsFeature
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.showKeyboard
import mozilla.components.support.utils.ext.bottom
import mozilla.components.support.utils.ext.left
import mozilla.components.support.utils.ext.right
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.R
import org.mozilla.fenix.collections.CollectionsListAdapter
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getDefaultCollectionNumber
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.HomeScreenViewModel
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private val args by navArgs<TabTrayDialogFragmentArgs>()
private val tabsFeature = ViewBoundFeatureWrapper<TabsFeature>()
private var _tabTrayView: TabTrayView? = null
private var currentOrientation: Int? = null
private val tabTrayView: TabTrayView
get() = _tabTrayView!!
private lateinit var tabTrayDialogStore: TabTrayDialogFragmentStore
private val snackbarAnchor: View?
get() =
// Fab is hidden when Talkback is activated. See #16592
if (requireContext().settings().accessibilityServicesEnabled) null
else if (tabTrayView.fabView.new_tab_button.isVisible ||
tabTrayView.mode != Mode.Normal
) tabTrayView.fabView.new_tab_button
/* During selection of the tabs to the collection, the FAB is not visible,
which leads to not attaching a needed AnchorView. That's why, we're not only
checking, if it's not visible, but also if we're not in a "Normal" mode, so after
selecting tabs for a collection, we're pushing snackbar
above the FAB, as we're switching from "Multiselect" to "Normal". */
else null
private val collectionStorageObserver = object : TabCollectionStorage.Observer {
override fun onCollectionCreated(title: String, sessions: List<TabSessionState>, id: Long?) {
showCollectionSnackbar(sessions.size, true, collectionToSelect = id)
}
override fun onTabsAdded(tabCollection: TabCollection, sessions: List<TabSessionState>) {
showCollectionSnackbar(
sessions.size,
collectionToSelect = tabCollection.id
)
}
}
private val selectTabUseCase = object : TabsUseCases.SelectTabUseCase {
override fun invoke(tabId: String) {
requireContext().components.analytics.metrics.track(Event.OpenedExistingTab)
requireComponents.useCases.tabsUseCases.selectTab(tabId)
navigateToBrowser()
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireContext(), this.theme) {
override fun onBackPressed() {
this@TabTrayDialogFragment.onBackPressed()
}
}
}
private val removeTabUseCase = object : TabsUseCases.RemoveTabUseCase {
override fun invoke(sessionId: String) {
requireContext().components.analytics.metrics.track(Event.ClosedExistingTab)
showUndoSnackbarForTab(sessionId)
removeIfNotLastTab(sessionId)
}
}
private fun removeIfNotLastTab(sessionId: String) {
// We only want to *immediately* remove a tab if there are more than one in the tab tray
// If there is only one, the HomeFragment handles deleting the tab (to better support snackbars)
val browserStore = requireComponents.core.store
val sessionToRemove = browserStore.state.findTab(sessionId)
sessionToRemove?.let {
if (browserStore.state.getNormalOrPrivateTabs(it.content.private).size != 1) {
requireComponents.useCases.tabsUseCases.removeTab(sessionId)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.TabTrayDialogStyle)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
tabTrayDialogStore = StoreProvider.get(this) {
TabTrayDialogFragmentStore(
TabTrayDialogFragmentState(
requireComponents.core.store.state,
if (args.enterMultiselect) Mode.MultiSelect(setOf()) else Mode.Normal
)
)
}
return inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
tabTrayView.setTopOffset(isLandscape)
if (newConfig.orientation != currentOrientation) {
tabTrayView.dismissMenu()
tabTrayView.updateBottomSheetBehavior()
if (requireContext().settings().gridTabView) {
// Update the number of columns to use in the grid view when the screen
// orientation changes.
tabTrayView.updateTabsTrayLayout()
}
currentOrientation = newConfig.orientation
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val activity = activity as HomeActivity
val isPrivate = activity.browsingModeManager.mode.isPrivate
val thumbnailLoader = ThumbnailLoader(requireContext().components.core.thumbnailStorage)
val adapter = FenixTabsAdapter(requireContext(), thumbnailLoader)
currentOrientation = resources.configuration.orientation
_tabTrayView = TabTrayView(
view.tabLayout,
adapter,
interactor = TabTrayFragmentInteractor(
DefaultTabTrayController(
activity = activity,
profiler = activity.components.core.engine.profiler,
browserStore = activity.components.core.store,
tabsUseCases = activity.components.useCases.tabsUseCases,
ioScope = lifecycleScope + Dispatchers.IO,
metrics = activity.components.analytics.metrics,
browsingModeManager = activity.browsingModeManager,
tabCollectionStorage = activity.components.core.tabCollectionStorage,
bookmarksStorage = activity.components.core.bookmarksStorage,
navController = findNavController(),
dismissTabTray = ::dismissAllowingStateLoss,
dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome,
registerCollectionStorageObserver = ::registerCollectionStorageObserver,
tabTrayDialogFragmentStore = tabTrayDialogStore,
selectTabUseCase = selectTabUseCase,
showChooseCollectionDialog = ::showChooseCollectionDialog,
showAddNewCollectionDialog = ::showAddNewCollectionDialog,
showUndoSnackbarForTabs = ::showUndoSnackbarForTabs,
showBookmarksSnackbar = ::showBookmarksSnackbar
)
),
isPrivate = isPrivate,
isInLandscape = ::isInLandscape,
lifecycleOwner = viewLifecycleOwner
) { private ->
val filter: (TabSessionState) -> Boolean = { state -> private == state.content.private }
tabsFeature.get()?.filterTabs(filter)
setSecureFlagsIfNeeded(private)
}
tabsFeature.set(
TabsFeature(
adapter,
view.context.components.core.store,
selectTabUseCase,
removeTabUseCase,
{ it.content.private == isPrivate },
{ }
),
owner = viewLifecycleOwner,
view = view
)
tabLayout.setOnClickListener {
requireContext().components.analytics.metrics.track(Event.TabsTrayClosed)
dismissAllowingStateLoss()
}
view.tabLayout.setOnApplyWindowInsetsListener { v, insets ->
// This will be addressed on https://github.com/mozilla-mobile/fenix/issues/17807
@Suppress("DEPRECATION")
v.updatePadding(
left = insets.left(),
right = insets.right(),
bottom = insets.bottom()
)
// This will be addressed on https://github.com/mozilla-mobile/fenix/issues/17807
@Suppress("DEPRECATION")
tabTrayView.view.tab_wrapper.updatePadding(
bottom = insets.bottom()
)
insets
}
consumeFrom(requireComponents.core.store) {
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.BrowserStateChanged(it))
}
consumeFrom(tabTrayDialogStore) {
tabTrayView.updateState(it)
}
}
private fun setSecureFlagsIfNeeded(private: Boolean) {
if (private && context?.settings()?.allowScreenshotsInPrivateMode == false) {
dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun showUndoSnackbarForTabs() {
lifecycleScope.allowUndo(
requireView().tabLayout,
getString(R.string.snackbar_message_tabs_closed),
getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
},
operation = { },
elevation = ELEVATION,
anchorView = snackbarAnchor
)
}
private fun showUndoSnackbarForTab(sessionId: String) {
val store = requireComponents.core.store
val tab = requireComponents.core.store.state.findTab(sessionId) ?: return
// Check if this is the last tab of this session type
val isLastOpenTab =
store.state.tabs.filter { it.content.private == tab.content.private }.size == 1
if (isLastOpenTab) {
dismissTabTrayAndNavigateHome(sessionId)
return
}
val snackbarMessage = if (tab.content.private) {
getString(R.string.snackbar_private_tab_closed)
} else {
getString(R.string.snackbar_tab_closed)
}
lifecycleScope.allowUndo(
requireView().tabLayout,
snackbarMessage,
getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
_tabTrayView?.scrollToSelectedBrowserTab(tab.id)
},
operation = { },
elevation = ELEVATION,
anchorView = snackbarAnchor
)
}
private val homeViewModel: HomeScreenViewModel by activityViewModels()
private fun dismissTabTrayAndNavigateHome(sessionId: String) {
homeViewModel.sessionToDelete = sessionId
val directions = NavGraphDirections.actionGlobalHome()
findNavController().navigateBlockingForAsyncNavGraph(directions)
dismissAllowingStateLoss()
}
override fun onDestroyView() {
_tabTrayView = null
super.onDestroyView()
}
fun navigateToBrowser() {
dismissAllowingStateLoss()
if (findNavController().currentDestination?.id == R.id.browserFragment) return
if (!findNavController().popBackStack(R.id.browserFragment, false)) {
findNavController().navigateBlockingForAsyncNavGraph(R.id.browserFragment)
}
}
private fun registerCollectionStorageObserver() {
requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
}
private fun showCollectionSnackbar(
tabSize: Int,
isNewCollection: Boolean = false,
collectionToSelect: Long?
) {
view.let {
val messageStringRes = when {
isNewCollection -> {
R.string.create_collection_tabs_saved_new_collection
}
tabSize > 1 -> {
R.string.create_collection_tabs_saved
}
else -> {
R.string.create_collection_tab_saved
}
}
val snackbar = FenixSnackbar
.make(
duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = true,
view = (view as View)
)
.setAnchorView(snackbarAnchor)
.setText(requireContext().getString(messageStringRes))
.setAction(requireContext().getString(R.string.create_collection_view)) {
dismissAllowingStateLoss()
findNavController().navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalHome(
focusOnAddressBar = false,
focusOnCollection = collectionToSelect ?: -1L
)
)
}
snackbar.view.elevation = ELEVATION
snackbar.show()
}
}
private fun showBookmarksSnackbar() {
val snackbar = FenixSnackbar
.make(
duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = false,
view = (view as View)
)
.setAnchorView(snackbarAnchor)
.setText(requireContext().getString(R.string.snackbar_message_bookmarks_saved))
.setAction(requireContext().getString(R.string.snackbar_message_bookmarks_view)) {
dismissAllowingStateLoss()
findNavController().navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id)
)
}
snackbar.view.elevation = ELEVATION
snackbar.show()
}
override fun onBackPressed(): Boolean {
if (!tabTrayView.onBackPressed()) {
dismiss()
}
return true
}
private fun showChooseCollectionDialog(sessionList: List<TabSessionState>) {
context?.let {
val tabCollectionStorage = it.components.core.tabCollectionStorage
val collections =
tabCollectionStorage.cachedTabCollections.map { it.title }.toTypedArray()
val customLayout =
LayoutInflater.from(it).inflate(R.layout.add_new_collection_dialog, null)
val list = customLayout.findViewById<RecyclerView>(R.id.recycler_view)
list.layoutManager = LinearLayoutManager(it)
val builder = AlertDialog.Builder(it).setTitle(R.string.tab_tray_select_collection)
.setView(customLayout)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
val selectedCollection =
(list.adapter as CollectionsListAdapter).getSelectedCollection()
val collection = tabCollectionStorage.cachedTabCollections[selectedCollection]
viewLifecycleOwner.lifecycleScope.launch(Main) {
tabCollectionStorage.addTabsToCollection(collection, sessionList)
it.metrics.track(
Event.CollectionTabsAdded(
it.components.core.store.state.normalTabs.size,
sessionList.size
)
)
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.dismiss()
}
}.setNegativeButton(android.R.string.cancel) { dialog, _ ->
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.cancel()
}
val dialog = builder.create()
val adapter =
CollectionsListAdapter(arrayOf(it.getString(R.string.tab_tray_add_new_collection)) + collections) {
dialog.dismiss()
showAddNewCollectionDialog(sessionList)
}
list.adapter = adapter
dialog.show()
}
}
private fun showAddNewCollectionDialog(sessionList: List<TabSessionState>) {
context?.let {
val tabCollectionStorage = it.components.core.tabCollectionStorage
val customLayout =
LayoutInflater.from(it).inflate(R.layout.name_collection_dialog, null)
val collectionNameEditText: EditText =
customLayout.findViewById(R.id.collection_name)
collectionNameEditText.setText(
it.getString(
R.string.create_collection_default_name,
tabCollectionStorage.cachedTabCollections.getDefaultCollectionNumber()
)
)
AlertDialog.Builder(it).setTitle(R.string.tab_tray_add_new_collection)
.setView(customLayout).setPositiveButton(android.R.string.ok) { dialog, _ ->
lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage.createCollection(
collectionNameEditText.text.toString(),
sessionList
)
it.metrics.track(
Event.CollectionSaved(
it.components.core.store.state.normalTabs.size,
sessionList.size
)
)
launch(Main) {
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.dismiss()
}
}
}.setNegativeButton(android.R.string.cancel) { dialog, _ ->
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.cancel()
}.create().show().also {
collectionNameEditText.setSelection(0, collectionNameEditText.text.length)
collectionNameEditText.showKeyboard()
}
}
}
private fun isInLandscape(): Boolean {
return requireContext().resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
companion object {
private const val ELEVATION = 80f
}
}

@ -1,76 +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.tabtray
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.concept.tabstray.Tab
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [Store] for holding the [TabTrayDialogFragmentState] and
* applying [TabTrayDialogFragmentAction]s.
*/
class TabTrayDialogFragmentStore(initialState: TabTrayDialogFragmentState) :
Store<TabTrayDialogFragmentState, TabTrayDialogFragmentAction>(
initialState,
::tabTrayStateReducer
)
/**
* Actions to dispatch through the `TabTrayDialogFragmentStore` to modify
* `TabTrayDialogFragmentState` through the reducer.
*/
sealed class TabTrayDialogFragmentAction : Action {
data class BrowserStateChanged(val browserState: BrowserState) : TabTrayDialogFragmentAction()
object EnterMultiSelectMode : TabTrayDialogFragmentAction()
object ExitMultiSelectMode : TabTrayDialogFragmentAction()
data class AddItemForCollection(val item: Tab) : TabTrayDialogFragmentAction()
data class RemoveItemForCollection(val item: Tab) : TabTrayDialogFragmentAction()
}
/**
* The state for the Tab Tray Dialog Screen
* @property mode Current Mode of Multiselection
*/
data class TabTrayDialogFragmentState(val browserState: BrowserState, val mode: Mode) : State {
sealed class Mode {
open val selectedItems = emptySet<Tab>()
object Normal : Mode()
data class MultiSelect(override val selectedItems: Set<Tab>) : Mode()
}
}
/**
* The TabTrayDialogFragmentState Reducer.
*/
private fun tabTrayStateReducer(
state: TabTrayDialogFragmentState,
action: TabTrayDialogFragmentAction
): TabTrayDialogFragmentState {
return when (action) {
is TabTrayDialogFragmentAction.BrowserStateChanged -> state.copy(browserState = action.browserState)
is TabTrayDialogFragmentAction.AddItemForCollection ->
state.copy(mode = TabTrayDialogFragmentState.Mode.MultiSelect(state.mode.selectedItems + action.item))
is TabTrayDialogFragmentAction.RemoveItemForCollection -> {
val selected = state.mode.selectedItems - action.item
state.copy(
mode = if (selected.isEmpty()) {
TabTrayDialogFragmentState.Mode.Normal
} else {
TabTrayDialogFragmentState.Mode.MultiSelect(selected)
}
)
}
is TabTrayDialogFragmentAction.ExitMultiSelectMode -> state.copy(mode = TabTrayDialogFragmentState.Mode.Normal)
is TabTrayDialogFragmentAction.EnterMultiSelectMode -> state.copy(
mode = TabTrayDialogFragmentState.Mode.MultiSelect(
setOf()
)
)
}
}

@ -1,170 +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.tabtray
import mozilla.components.concept.tabstray.Tab
@Suppress("TooManyFunctions")
interface TabTrayInteractor {
/**
* Called when user clicks the new tab button.
*/
fun onNewTabTapped(private: Boolean)
/**
* Called when tab tray should be dismissed.
*/
fun onTabTrayDismissed()
/**
* Called when user clicks the share tabs button.
*/
fun onShareTabsOfTypeClicked(private: Boolean)
/**
* Called when user clicks button to share selected tabs in multiselect.
*/
fun onShareSelectedTabsClicked(selectedTabs: Set<Tab>)
/**
* Called when user clicks bookmark button in menu to bookmark selected tabs in multiselect.
*/
fun onBookmarkSelectedTabs(selectedTabs: Set<Tab>)
/**
* Called when user clicks delete button in menu to delete selected tabs in multiselect.
*/
fun onDeleteSelectedTabs(selectedTabs: Set<Tab>)
/**
* Called when user clicks the tab settings button.
*/
fun onTabSettingsClicked()
/**
* Called when user clicks button to save selected tabs to a collection.
*/
fun onSaveToCollectionClicked(selectedTabs: Set<Tab>)
/**
* Called when user clicks the close all tabs button.
*/
fun onCloseAllTabsClicked(private: Boolean)
/**
* Called when the physical back button is clicked.
*/
fun onBackPressed(): Boolean
/**
* Called when a requester needs to know the current mode of the tab tray.
*/
fun onModeRequested(): TabTrayDialogFragmentState.Mode
/**
* Called when user clicks on the action button prompt in the info banner CFR for
* automatically closing tabs or changing the layout of open tabs.
*/
fun onGoToTabsSettings()
/**
* Called when a tab should be opened in the browser.
*/
fun onOpenTab(tab: Tab)
/**
* Called when a tab should be selected in multiselect mode.
*/
fun onAddSelectedTab(tab: Tab)
/**
* Called when a tab should be unselected in multiselect mode.
*/
fun onRemoveSelectedTab(tab: Tab)
/**
* Called when multiselect mode should be entered with no tabs selected.
*/
fun onEnterMultiselect()
/**
* Called when user clicks the recently closed tabs menu button.
*/
fun onOpenRecentlyClosedClicked()
}
/**
* Interactor for the tab tray fragment.
*/
@Suppress("TooManyFunctions")
class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor {
override fun onNewTabTapped(private: Boolean) {
controller.handleNewTabTapped(private)
}
override fun onTabTrayDismissed() {
controller.handleTabTrayDismissed()
}
override fun onTabSettingsClicked() {
controller.handleTabSettingsClicked()
}
override fun onOpenRecentlyClosedClicked() {
controller.handleRecentlyClosedClicked()
}
override fun onShareTabsOfTypeClicked(private: Boolean) {
controller.handleShareTabsOfTypeClicked(private)
}
override fun onShareSelectedTabsClicked(selectedTabs: Set<Tab>) {
controller.handleShareSelectedTabsClicked(selectedTabs)
}
override fun onBookmarkSelectedTabs(selectedTabs: Set<Tab>) {
controller.handleBookmarkSelectedTabs(selectedTabs)
}
override fun onDeleteSelectedTabs(selectedTabs: Set<Tab>) {
controller.handleDeleteSelectedTabs(selectedTabs)
}
override fun onSaveToCollectionClicked(selectedTabs: Set<Tab>) {
controller.handleSaveToCollectionClicked(selectedTabs)
}
override fun onCloseAllTabsClicked(private: Boolean) {
controller.handleCloseAllTabsClicked(private)
}
override fun onBackPressed(): Boolean {
return controller.handleBackPressed()
}
override fun onModeRequested(): TabTrayDialogFragmentState.Mode {
return controller.onModeRequested()
}
override fun onAddSelectedTab(tab: Tab) {
controller.handleAddSelectedTab(tab)
}
override fun onRemoveSelectedTab(tab: Tab) {
controller.handleRemoveSelectedTab(tab)
}
override fun onOpenTab(tab: Tab) {
controller.handleOpenTab(tab)
}
override fun onEnterMultiselect() {
controller.handleEnterMultiselect()
}
override fun onGoToTabsSettings() {
controller.handleGoToTabsSettingClicked()
}
}

@ -1,72 +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.tabtray
import android.content.Context
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
class TabTrayItemMenu(
private val context: Context,
private val shouldShowShareAllTabs: () -> Boolean,
private val shouldShowSelectTabs: () -> Boolean,
private val hasOpenTabs: () -> Boolean,
private val onItemTapped: (Item) -> Unit = {}
) {
sealed class Item {
object ShareAllTabs : Item()
object OpenTabSettings : Item()
object SelectTabs : Item()
object CloseAllTabs : Item()
object OpenRecentlyClosed : Item()
}
val menuBuilder by lazy { BrowserMenuBuilder(menuItems) }
private val menuItems by lazy {
listOf(
SimpleBrowserMenuItem(
context.getString(R.string.tabs_tray_select_tabs),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.SelectTabs)
}.apply { visible = shouldShowSelectTabs },
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_menu_item_share),
textColorResource = R.color.primary_text_normal_theme
) {
context.components.analytics.metrics.track(Event.TabsTrayShareAllTabsPressed)
onItemTapped.invoke(Item.ShareAllTabs)
}.apply { visible = { shouldShowShareAllTabs() && hasOpenTabs() } },
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_menu_tab_settings),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.OpenTabSettings)
},
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_menu_recently_closed),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.OpenRecentlyClosed)
},
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_menu_item_close),
textColorResource = R.color.primary_text_normal_theme
) {
context.components.analytics.metrics.track(Event.TabsTrayCloseAllTabsPressed)
onItemTapped.invoke(Item.CloseAllTabs)
}.apply { visible = hasOpenTabs }
)
}
}

@ -1,744 +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.tabtray
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import androidx.annotation.IdRes
import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_tabstray.view.*
import kotlinx.android.synthetic.main.component_tabstray_fab.view.*
import kotlinx.android.synthetic.main.tabs_tray_tab_counter.*
import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.support.ktx.android.util.dpToPx
import mozilla.components.ui.tabcounter.TabCounter.Companion.INFINITE_CHAR_PADDING_BOTTOM
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import mozilla.components.ui.tabcounter.TabCounter.Companion.MAX_VISIBLE_TABS
import mozilla.components.ui.tabcounter.TabCounter.Companion.SO_MANY_TABS_OPEN
import org.mozilla.fenix.browser.infobanner.InfoBanner
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.updateAccessibilityCollectionInfo
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.MultiselectModeChange
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode
import org.mozilla.fenix.utils.Settings
import java.text.NumberFormat
import kotlin.math.max
import kotlin.math.roundToInt
/**
* View that contains and configures the BrowserAwesomeBar
*/
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "ForbiddenComment")
class TabTrayView(
private val container: ViewGroup,
private val tabsAdapter: FenixTabsAdapter,
private val interactor: TabTrayInteractor,
isPrivate: Boolean,
private val isInLandscape: () -> Boolean,
lifecycleOwner: LifecycleOwner,
private val filterTabs: (Boolean) -> Unit
) : LayoutContainer, TabLayout.OnTabSelectedListener {
val lifecycleScope = lifecycleOwner.lifecycleScope
val fabView = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray_fab, container, true)
private val hasAccessibilityEnabled = container.context.settings().accessibilityServicesEnabled
val view = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray, container, true)
private val isPrivateModeSelected: Boolean get() = view.tab_layout.selectedTabPosition == PRIVATE_TAB_ID
private val behavior = BottomSheetBehavior.from(view.tab_wrapper)
private val concatAdapter = ConcatAdapter(tabsAdapter)
private val tabTrayItemMenu: TabTrayItemMenu
private var menu: BrowserMenu? = null
private val multiselectSelectionMenu: MultiselectSelectionMenu
private var multiselectMenu: BrowserMenu? = null
private var tabsTouchHelper: TabsTouchHelper
private val collectionsButtonAdapter =
SaveToCollectionsButtonAdapter(interactor) { isPrivateModeSelected }
private var hasLoaded = false
override val containerView: View?
get() = container
private val components = container.context.components
private val checkOpenTabs = {
if (isPrivateModeSelected) {
view.context.components.core.store.state.privateTabs.isNotEmpty()
} else {
view.context.components.core.store.state.normalTabs.isNotEmpty()
}
}
init {
components.analytics.metrics.track(Event.TabsTrayOpened)
toggleFabText(isPrivate)
view.topBar.setOnClickListener {
// no-op, consume the touch event to prevent it advancing the tray to the next state.
}
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (
interactor.onModeRequested() is Mode.Normal &&
!hasAccessibilityEnabled &&
slideOffset >= SLIDE_OFFSET
) {
fabView.new_tab_button.show()
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
components.analytics.metrics.track(Event.TabsTrayClosed)
interactor.onTabTrayDismissed()
}
// We only support expanded and collapsed states. Don't allow STATE_HALF_EXPANDED.
else if (newState == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}
})
val selectedTabIndex = if (!isPrivate) {
DEFAULT_TAB_ID
} else {
PRIVATE_TAB_ID
}
view.tab_layout.getTabAt(selectedTabIndex)?.also {
view.tab_layout.selectTab(it, true)
}
view.tab_layout.addOnTabSelectedListener(this)
val tabs = getTabs(isPrivate)
updateBottomSheetBehavior()
setTopOffset(isInLandscape())
updateTabsTrayLayout()
view.tabsTray.apply {
adapter = concatAdapter
tabsTouchHelper = TabsTouchHelper(
observable = tabsAdapter,
onViewHolderTouched = { it is TabViewHolder }
)
tabsTouchHelper.attachToRecyclerView(this)
tabsAdapter.tabTrayInteractor = interactor
tabsAdapter.onTabsUpdated = {
concatAdapter.addAdapter(collectionsButtonAdapter)
if (hasAccessibilityEnabled) {
tabsAdapter.notifyItemRangeChanged(0, tabs.size)
}
if (!hasLoaded) {
hasLoaded = true
scrollToSelectedBrowserTab()
if (view.context.settings().accessibilityServicesEnabled) {
lifecycleScope.launch {
delay(SELECTION_DELAY.toLong())
lifecycleScope.launch(Main) {
layoutManager?.findViewByPosition(getSelectedBrowserTabViewIndex())
?.requestFocus()
layoutManager?.findViewByPosition(getSelectedBrowserTabViewIndex())
?.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_FOCUSED
)
}
}
}
}
}
}
tabTrayItemMenu =
TabTrayItemMenu(
context = view.context,
shouldShowShareAllTabs = { checkOpenTabs.invoke() && view.tab_layout.selectedTabPosition == 0 },
shouldShowSelectTabs = { checkOpenTabs.invoke() && view.tab_layout.selectedTabPosition == 0 },
hasOpenTabs = checkOpenTabs
) {
when (it) {
is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsOfTypeClicked(
isPrivateModeSelected
)
is TabTrayItemMenu.Item.OpenTabSettings -> interactor.onTabSettingsClicked()
is TabTrayItemMenu.Item.SelectTabs -> interactor.onEnterMultiselect()
is TabTrayItemMenu.Item.CloseAllTabs -> interactor.onCloseAllTabsClicked(
isPrivateModeSelected
)
is TabTrayItemMenu.Item.OpenRecentlyClosed -> interactor.onOpenRecentlyClosedClicked()
}
}
multiselectSelectionMenu = MultiselectSelectionMenu(
context = view.context
) {
when (it) {
is MultiselectSelectionMenu.Item.BookmarkTabs -> interactor.onBookmarkSelectedTabs(
mode.selectedItems
)
is MultiselectSelectionMenu.Item.DeleteTabs -> interactor.onDeleteSelectedTabs(
mode.selectedItems
)
}
}
view.tab_tray_overflow.setOnClickListener {
components.analytics.metrics.track(Event.TabsTrayMenuOpened)
menu = tabTrayItemMenu.menuBuilder.build(container.context)
menu?.show(it)?.also { popupMenu ->
(popupMenu.contentView as? CardView)?.setCardBackgroundColor(
ContextCompat.getColor(
view.context,
R.color.foundation_normal_theme
)
)
}
}
adjustNewTabButtonsForNormalMode()
displayInfoBannerIfNeccessary(tabs, view.context.settings())
}
private fun displayInfoBannerIfNeccessary(tabs: List<TabSessionState>, settings: Settings) {
@Suppress("ComplexCondition")
val infoBanner = if (
settings.shouldShowGridViewBanner &&
settings.canShowCfr &&
settings.listTabView &&
tabs.size >= TAB_COUNT_SHOW_CFR
) {
InfoBanner(
context = view.context,
message = view.context.getString(R.string.tab_tray_grid_view_banner_message),
dismissText = view.context.getString(R.string.tab_tray_grid_view_banner_negative_button_text),
actionText = view.context.getString(R.string.tab_tray_grid_view_banner_positive_button_text),
container = view.infoBanner,
dismissByHiding = true,
dismissAction = {
components.analytics.metrics.track(Event.TabsTrayCfrDismissed)
settings.shouldShowGridViewBanner = false
}
) {
interactor.onGoToTabsSettings()
settings.shouldShowGridViewBanner = false
}
} else if (
settings.shouldShowAutoCloseTabsBanner &&
settings.canShowCfr &&
tabs.size >= TAB_COUNT_SHOW_CFR
) {
InfoBanner(
context = view.context,
message = view.context.getString(R.string.tab_tray_close_tabs_banner_message),
dismissText = view.context.getString(R.string.tab_tray_close_tabs_banner_negative_button_text),
actionText = view.context.getString(R.string.tab_tray_close_tabs_banner_positive_button_text),
container = view.infoBanner,
dismissByHiding = true,
dismissAction = { settings.shouldShowAutoCloseTabsBanner = false }
) {
interactor.onGoToTabsSettings()
settings.shouldShowAutoCloseTabsBanner = false
}
} else {
null
}
infoBanner?.apply {
view.infoBanner.visibility = VISIBLE
showBanner()
}
}
private fun getTabs(isPrivate: Boolean): List<TabSessionState> = if (isPrivate) {
view.context.components.core.store.state.privateTabs
} else {
view.context.components.core.store.state.normalTabs
}
private fun getTabsNumberInAnyMode(): Int {
return max(
view.context.components.core.store.state.normalTabs.size,
view.context.components.core.store.state.privateTabs.size
)
}
private fun getTabsNumberForExpandingTray(): Int {
return if (container.context.settings().gridTabView) {
EXPAND_AT_GRID_SIZE
} else {
EXPAND_AT_LIST_SIZE
}
}
private fun adjustNewTabButtonsForNormalMode() {
view.tab_tray_new_tab.apply {
isVisible = hasAccessibilityEnabled
setOnClickListener {
sendNewTabEvent(isPrivateModeSelected)
interactor.onNewTabTapped(isPrivateModeSelected)
}
}
fabView.new_tab_button.apply {
isVisible = !hasAccessibilityEnabled
setOnClickListener {
sendNewTabEvent(isPrivateModeSelected)
interactor.onNewTabTapped(isPrivateModeSelected)
}
}
}
private fun sendNewTabEvent(isPrivateModeSelected: Boolean) {
val eventToSend = if (isPrivateModeSelected) {
Event.NewPrivateTabTapped
} else {
Event.NewTabTapped
}
components.analytics.metrics.track(eventToSend)
}
/**
* Updates the bottom sheet height based on the number tabs or screen orientation.
* Show the bottom sheet fully expanded if it is in landscape mode or the number of
* tabs are greater or equal to the expand size limit.
*/
fun updateBottomSheetBehavior() {
if (isInLandscape() || getTabsNumberInAnyMode() >= getTabsNumberForExpandingTray()) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
enum class TabChange {
PRIVATE, NORMAL
}
private fun toggleSaveToCollectionButton(isPrivate: Boolean) {
collectionsButtonAdapter.notifyItemChanged(
0,
if (isPrivate) TabChange.PRIVATE else TabChange.NORMAL
)
}
override fun onTabSelected(tab: TabLayout.Tab?) {
toggleFabText(isPrivateModeSelected)
filterTabs.invoke(isPrivateModeSelected)
toggleSaveToCollectionButton(isPrivateModeSelected)
updateUINormalMode(view.context.components.core.store.state)
scrollToSelectedBrowserTab()
if (isPrivateModeSelected) {
components.analytics.metrics.track(Event.TabsTrayPrivateModeTapped)
} else {
components.analytics.metrics.track(Event.TabsTrayNormalModeTapped)
}
}
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
var mode: Mode = Mode.Normal
private set
fun updateTabsTrayLayout() {
if (container.context.settings().gridTabView) {
setupGridTabView()
} else {
setupListTabView()
}
}
private fun setupGridTabView() {
view.tabsTray.apply {
val gridLayoutManager =
GridLayoutManager(container.context, getNumberOfGridColumns(container.context))
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val numTabs = tabsAdapter.itemCount
return if (position < numTabs) {
1
} else {
getNumberOfGridColumns(container.context)
}
}
}
layoutManager = gridLayoutManager
// Ensure items have the same all around padding - 16 dp. Avoid the double spacing issue.
// A 8dp padding is already set in xml, pad the parent with the remaining needed 8dp.
updateLayoutParams<ConstraintLayout.LayoutParams> {
val padding = GRID_ITEM_PARENT_PADDING.dpToPx(resources.displayMetrics)
// Account for the already set bottom padding needed to accommodate the fab.
val bottomPadding = paddingBottom + padding
setPadding(padding, padding, padding, bottomPadding)
}
}
}
/**
* Returns the number of columns that will fit in the grid layout for the current screen.
*/
private fun getNumberOfGridColumns(context: Context): Int {
val displayMetrics = context.resources.displayMetrics
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
val columnCount = (screenWidthDp / COLUMN_WIDTH_DP).toInt()
return if (columnCount >= 2) columnCount else 2
}
private fun setupListTabView() {
view.tabsTray.apply {
layoutManager = LinearLayoutManager(container.context)
}
}
fun updateState(state: TabTrayDialogFragmentState) {
val oldMode = mode
if (oldMode::class != state.mode::class) {
updateTabsForMultiselectModeChanged(state.mode is Mode.MultiSelect)
if (view.context.settings().accessibilityServicesEnabled) {
view.announceForAccessibility(
if (state.mode == Mode.Normal) view.context.getString(
R.string.tab_tray_exit_multiselect_content_description
) else view.context.getString(R.string.tab_tray_enter_multiselect_content_description)
)
}
}
mode = state.mode
when (state.mode) {
Mode.Normal -> {
view.tabsTray.apply {
tabsTouchHelper.attachToRecyclerView(this)
}
toggleUIMultiselect(multiselect = false)
updateUINormalMode(state.browserState)
}
is Mode.MultiSelect -> {
// Disable swipe to delete while in multiselect
tabsTouchHelper.attachToRecyclerView(null)
toggleUIMultiselect(multiselect = true)
fabView.new_tab_button.isVisible = false
view.tab_tray_new_tab.isVisible = false
view.collect_multi_select.isVisible = state.mode.selectedItems.isNotEmpty()
view.share_multi_select.isVisible = state.mode.selectedItems.isNotEmpty()
view.menu_multi_select.isVisible = state.mode.selectedItems.isNotEmpty()
view.multiselect_title.text = view.context.getString(
R.string.tab_tray_multi_select_title,
state.mode.selectedItems.size
)
view.collect_multi_select.setOnClickListener {
interactor.onSaveToCollectionClicked(state.mode.selectedItems)
}
view.share_multi_select.setOnClickListener {
interactor.onShareSelectedTabsClicked(state.mode.selectedItems)
}
view.menu_multi_select.setOnClickListener {
multiselectMenu = multiselectSelectionMenu.menuBuilder.build(container.context)
multiselectMenu?.show(it)?.also { popupMenu ->
(popupMenu.contentView as? CardView)?.setCardBackgroundColor(
ContextCompat.getColor(
view.context,
R.color.foundation_normal_theme
)
)
}
}
view.exit_multi_select.setOnClickListener {
interactor.onBackPressed()
}
}
}
if (oldMode.selectedItems != state.mode.selectedItems) {
val unselectedItems = oldMode.selectedItems - state.mode.selectedItems
state.mode.selectedItems.union(unselectedItems).forEach { item ->
if (view.context.settings().accessibilityServicesEnabled) {
view.announceForAccessibility(
if (unselectedItems.contains(item)) view.context.getString(
R.string.tab_tray_item_unselected_multiselect_content_description,
item.title
) else view.context.getString(
R.string.tab_tray_item_selected_multiselect_content_description,
item.title
)
)
}
updateTabsForSelectionChanged(item.id)
}
}
}
private fun ConstraintLayout.setChildWPercent(percentage: Float, @IdRes childId: Int) {
this.findViewById<View>(childId)?.let {
val constraintSet = ConstraintSet()
constraintSet.clone(this)
constraintSet.constrainPercentWidth(it.id, percentage)
constraintSet.applyTo(this)
it.requestLayout()
}
}
private fun updateUINormalMode(browserState: BrowserState) {
val hasNoTabs = if (isPrivateModeSelected) {
browserState.privateTabs.isEmpty()
} else {
browserState.normalTabs.isEmpty()
}
view.tab_tray_empty_view.isVisible = hasNoTabs
if (hasNoTabs) {
view.tab_tray_empty_view.text = if (isPrivateModeSelected) {
view.context.getString(R.string.no_private_tabs_description)
} else {
view.context?.getString(R.string.no_open_tabs_description)
}
}
view.tabsTray.visibility = if (hasNoTabs) {
INVISIBLE
} else {
VISIBLE
}
counter_text.text = updateTabCounter(browserState.normalTabs.size)
updateTabTrayViewAccessibility(browserState.normalTabs.size)
adjustNewTabButtonsForNormalMode()
}
private fun toggleUIMultiselect(multiselect: Boolean) {
view.multiselect_title.isVisible = multiselect
view.collect_multi_select.isVisible = multiselect
view.share_multi_select.isVisible = multiselect
view.menu_multi_select.isVisible = multiselect
view.exit_multi_select.isVisible = multiselect
view.topBar.setBackgroundColor(
ContextCompat.getColor(
view.context,
if (multiselect) R.color.accent_normal_theme else R.color.foundation_normal_theme
)
)
view.handle.updateLayoutParams<ViewGroup.MarginLayoutParams> {
height = view.resources.getDimensionPixelSize(
if (multiselect) {
R.dimen.tab_tray_multiselect_handle_height
} else {
R.dimen.bottom_sheet_handle_height
}
)
topMargin = view.resources.getDimensionPixelSize(
if (multiselect) {
R.dimen.tab_tray_multiselect_handle_top_margin
} else {
R.dimen.bottom_sheet_handle_top_margin
}
)
}
view.tab_wrapper.setChildWPercent(
if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH,
view.handle.id
)
view.handle.setBackgroundColor(
ContextCompat.getColor(
view.context,
if (multiselect) R.color.accent_normal_theme else R.color.secondary_text_normal_theme
)
)
view.tab_layout.isVisible = !multiselect
view.tab_tray_empty_view.isVisible = !multiselect
view.tab_tray_overflow.isVisible = !multiselect
view.tab_layout.isVisible = !multiselect
}
private fun updateTabsForMultiselectModeChanged(inMultiselectMode: Boolean) {
view.tabsTray.apply {
val tabs = view.context.components.core.store.state.getNormalOrPrivateTabs(
isPrivateModeSelected
)
collectionsButtonAdapter.notifyItemChanged(
0,
if (inMultiselectMode) MultiselectModeChange.MULTISELECT else MultiselectModeChange.NORMAL
)
tabsAdapter.notifyItemRangeChanged(0, tabs.size, true)
}
}
private fun updateTabsForSelectionChanged(itemId: String) {
view.tabsTray.apply {
val tabs = view.context.components.core.store.state.getNormalOrPrivateTabs(
isPrivateModeSelected
)
val selectedBrowserTabIndex = tabs.indexOfFirst { it.id == itemId }
tabsAdapter.notifyItemChanged(
selectedBrowserTabIndex, true
)
}
}
private fun updateTabTrayViewAccessibility(count: Int) {
view.tab_layout.getTabAt(0)?.contentDescription = if (count == 1) {
view.context?.getString(R.string.open_tab_tray_single)
} else {
String.format(view.context.getString(R.string.open_tab_tray_plural), count.toString())
}
val isListTabView = view.context.settings().listTabView
val columnCount = if (isListTabView) 1 else getNumberOfGridColumns(view.context)
val rowCount = count.toDouble().div(columnCount).roundToInt()
view.tabsTray.updateAccessibilityCollectionInfo(rowCount, columnCount)
}
private fun updateTabCounter(count: Int): String {
if (count > MAX_VISIBLE_TABS) {
counter_text.updatePadding(bottom = INFINITE_CHAR_PADDING_BOTTOM)
return SO_MANY_TABS_OPEN
}
return NumberFormat.getInstance().format(count.toLong())
}
fun setTopOffset(landscape: Boolean) {
val topOffset = if (landscape) {
0
} else {
view.resources.getDimensionPixelSize(R.dimen.tab_tray_top_offset)
}
behavior.expandedOffset = topOffset
}
fun dismissMenu() {
menu?.dismiss()
}
private fun toggleFabText(private: Boolean) {
if (private) {
fabView.new_tab_button.extend()
fabView.new_tab_button.contentDescription =
view.context.getString(R.string.add_private_tab)
} else {
fabView.new_tab_button.shrink()
fabView.new_tab_button.contentDescription =
view.context.getString(R.string.add_tab)
}
}
fun onBackPressed(): Boolean {
return interactor.onBackPressed()
}
fun scrollToSelectedBrowserTab(selectedTabId: String? = null) {
view.tabsTray.apply {
val recyclerViewIndex = getSelectedBrowserTabViewIndex(selectedTabId)
layoutManager?.scrollToPosition(recyclerViewIndex)
smoothScrollBy(
0,
-resources.getDimensionPixelSize(R.dimen.tab_tray_tab_item_height) / 2
)
}
}
private fun getSelectedBrowserTabViewIndex(sessionId: String? = null): Int {
val tabs = if (isPrivateModeSelected) {
view.context.components.core.store.state.privateTabs
} else {
view.context.components.core.store.state.normalTabs
}
return if (sessionId != null) {
tabs.indexOfFirst { it.id == sessionId }
} else {
tabs.indexOfFirst { it.id == view.context.components.core.store.state.selectedTabId }
}
}
companion object {
private const val TAB_COUNT_SHOW_CFR = 6
private const val DEFAULT_TAB_ID = 0
private const val PRIVATE_TAB_ID = 1
// Minimum number of list items for which to show the tabs tray as expanded.
private const val EXPAND_AT_LIST_SIZE = 4
// Minimum number of grid items for which to show the tabs tray as expanded.
private const val EXPAND_AT_GRID_SIZE = 3
private const val SLIDE_OFFSET = 0
private const val SELECTION_DELAY = 500
private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F
private const val COLUMN_WIDTH_DP = 180
// The remaining padding offset needed to provide a 16dp column spacing between the grid items.
const val GRID_ITEM_PARENT_PADDING = 8
}
}

@ -1,223 +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.tabtray
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.tab_tray_grid_item.view.*
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import mozilla.components.concept.base.images.ImageLoadRequest
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.engine.mediasession.MediaSession
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.removeAndDisable
import org.mozilla.fenix.ext.removeTouchDelegate
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.ext.toShortUrl
import kotlin.math.max
/**
* A RecyclerView ViewHolder implementation for "tab" items.
*/
class TabTrayViewHolder(
itemView: View,
private val imageLoader: ImageLoader,
private val store: BrowserStore = itemView.context.components.core.store,
private val metrics: MetricController = itemView.context.components.analytics.metrics
) : TabViewHolder(itemView) {
private val faviconView: ImageView? =
itemView.findViewById(R.id.mozac_browser_tabstray_favicon_icon)
private val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title)
private val closeView: AppCompatImageButton =
itemView.findViewById(R.id.mozac_browser_tabstray_close)
private val thumbnailView: TabThumbnailView =
itemView.findViewById(R.id.mozac_browser_tabstray_thumbnail)
@VisibleForTesting
internal val urlView: TextView? = itemView.findViewById(R.id.mozac_browser_tabstray_url)
private val playPauseButtonView: ImageButton = itemView.findViewById(R.id.play_pause_button)
private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close)
override var tab: Tab? = null
/**
* Displays the data of the given session and notifies the given observable about events.
*/
@Suppress("ComplexMethod", "LongMethod")
override fun bind(
tab: Tab,
isSelected: Boolean,
styling: TabsTrayStyling,
observable: Observable<TabsTray.Observer>
) {
this.tab = tab
updateTitle(tab)
updateUrl(tab)
updateFavicon(tab)
updateCloseButtonDescription(tab.title)
updateSelectedTabIndicator(isSelected)
if (tab.thumbnail != null) {
thumbnailView.setImageBitmap(tab.thumbnail)
} else {
loadIntoThumbnailView(thumbnailView, tab.id)
}
if (itemView.context.settings().gridTabView) {
closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS)
}
// Media state
playPauseButtonView.increaseTapArea(PLAY_PAUSE_BUTTON_EXTRA_DPS)
with(playPauseButtonView) {
invalidate()
val sessionState = store.state.findTabOrCustomTab(tab.id)
when (sessionState?.mediaSessionState?.playbackState) {
MediaSession.PlaybackState.PAUSED -> {
showAndEnable()
contentDescription =
context.getString(R.string.mozac_feature_media_notification_action_play)
setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.media_state_play)
)
}
MediaSession.PlaybackState.PLAYING -> {
showAndEnable()
contentDescription =
context.getString(R.string.mozac_feature_media_notification_action_pause)
setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.media_state_pause)
)
}
else -> {
removeTouchDelegate()
removeAndDisable()
}
}
setOnClickListener {
when (sessionState?.mediaSessionState?.playbackState) {
MediaSession.PlaybackState.PLAYING -> {
metrics.track(Event.TabMediaPause)
sessionState.mediaSessionState?.controller?.pause()
}
MediaSession.PlaybackState.PAUSED -> {
metrics.track(Event.TabMediaPlay)
sessionState.mediaSessionState?.controller?.play()
}
else -> throw AssertionError(
"Play/Pause button clicked without play/pause state."
)
}
}
}
closeView.setOnClickListener {
observable.notifyObservers { onTabClosed(tab) }
}
}
private fun updateFavicon(tab: Tab) {
if (tab.icon != null) {
faviconView?.visibility = View.VISIBLE
faviconView?.setImageBitmap(tab.icon)
} else {
faviconView?.visibility = View.GONE
}
}
private fun updateTitle(tab: Tab) {
val title = if (tab.title.isNotEmpty()) {
tab.title
} else {
tab.url
}
titleView.text = title
}
private fun updateUrl(tab: Tab) {
// Truncate to MAX_URI_LENGTH to prevent the UI from locking up for
// extremely large URLs such as data URIs or bookmarklets. The same
// is done in the toolbar and awesomebar:
// https://github.com/mozilla-mobile/fenix/issues/1824
// https://github.com/mozilla-mobile/android-components/issues/6985
urlView?.text = tab.url
.toShortUrl(itemView.context.components.publicSuffixList)
.take(MAX_URI_LENGTH)
}
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
if (itemView.context.settings().gridTabView) {
itemView.tab_tray_grid_item.background = if (showAsSelected) {
AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border)
} else {
null
}
return
}
val color = if (showAsSelected) {
R.color.tab_tray_item_selected_background_normal_theme
} else {
R.color.tab_tray_item_background_normal_theme
}
itemView.setBackgroundColor(
ContextCompat.getColor(
itemView.context,
color
)
)
}
private fun updateCloseButtonDescription(title: String) {
closeView.contentDescription =
closeView.context.getString(R.string.close_tab_title, title)
}
private fun loadIntoThumbnailView(thumbnailView: ImageView, id: String) {
val thumbnailSize = if (itemView.context.settings().gridTabView) {
max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width)
)
} else {
max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width)
)
}
imageLoader.loadIntoView(thumbnailView, ImageLoadRequest(id, thumbnailSize))
}
companion object {
private const val PLAY_PAUSE_BUTTON_EXTRA_DPS = 24
private const val GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS = 24
}
}

@ -1,132 +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.tabtray
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import androidx.appcompat.content.res.AppCompatResources
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabTouchCallback
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.android.content.getDrawableWithTint
import mozilla.components.support.ktx.android.util.dpToPx
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.sessioncontrol.SwipeToDeleteCallback
/**
* A callback for consumers to know when a [RecyclerView.ViewHolder] is about to be touched.
* Return false if the default behaviour should be ignored.
*/
typealias OnViewHolderTouched = (RecyclerView.ViewHolder) -> Boolean
/**
* An [ItemTouchHelper] for handling tab swiping to delete.
*
* @param onViewHolderTouched See [OnViewHolderTouched].
*/
class TabsTouchHelper(
observable: Observable<TabsTray.Observer>,
onViewHolderTouched: OnViewHolderTouched = { true },
delegate: Callback = TouchCallback(observable, onViewHolderTouched)
) : ItemTouchHelper(delegate)
/**
* An [ItemTouchHelper.Callback] for drawing custom layouts on [RecyclerView.ViewHolder] interactions.
*
* @param onViewHolderTouched invoked when a tab is about to be swiped. See [OnViewHolderTouched].
*/
class TouchCallback(
observable: Observable<TabsTray.Observer>,
private val onViewHolderTouched: OnViewHolderTouched
) : TabTouchCallback(observable) {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
if (!onViewHolderTouched.invoke(viewHolder)) {
return ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0)
}
return super.getMovementFlags(recyclerView, viewHolder)
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
if (recyclerView.context.settings().gridTabView) {
return
}
val icon = recyclerView.context.getDrawableWithTint(
R.drawable.ic_delete,
recyclerView.context.getColorFromAttr(R.attr.destructive)
)!!
val background = AppCompatResources.getDrawable(
recyclerView.context,
R.drawable.swipe_delete_background
)!!
val itemView = viewHolder.itemView
val iconLeft: Int
val iconRight: Int
val margin =
SwipeToDeleteCallback.MARGIN.dpToPx(recyclerView.resources.displayMetrics)
val iconWidth = icon.intrinsicWidth
val iconHeight = icon.intrinsicHeight
val cellHeight = itemView.bottom - itemView.top
val iconTop = itemView.top + (cellHeight - iconHeight) / 2
val iconBottom = iconTop + iconHeight
when {
dX > 0 -> { // Swiping to the right
iconLeft = itemView.left + margin
iconRight = itemView.left + margin + iconWidth
background.setBounds(
itemView.left, itemView.top,
(itemView.left + dX).toInt() + SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
dX < 0 -> { // Swiping to the left
iconLeft = itemView.right - margin - iconWidth
iconRight = itemView.right - margin
background.setBounds(
(itemView.right + dX).toInt() - SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.top, itemView.right, itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
else -> { // View not swiped
background.setBounds(0, 0, 0, 0)
icon.setBounds(0, 0, 0, 0)
}
}
}
private fun draw(
background: Drawable,
icon: Drawable,
c: Canvas
) {
background.draw(c)
icon.draw(c)
}
}

@ -351,12 +351,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = false
)
var tabsTrayRewrite by featureFlagPreference(
appContext.getPreferenceKey(R.string.pref_key_new_tabs_tray),
default = true,
featureFlag = FeatureFlags.tabsTrayRewrite
)
var allowThirdPartyRootCerts by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_allow_third_party_root_certs),
default = false

@ -1,171 +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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tab_wrapper"
style="@style/BottomSheetModal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/foundation_normal_theme"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<View
android:id="@+id/handle"
android:layout_width="0dp"
android:layout_height="@dimen/bottom_sheet_handle_height"
android:layout_marginTop="@dimen/bottom_sheet_handle_top_margin"
android:background="@color/secondary_text_normal_theme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.1" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/infoBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/foundation_normal_theme"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/topBar" />
<TextView
android:id="@+id/tab_tray_empty_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center_horizontal"
android:paddingTop="80dp"
android:text="@string/no_open_tabs_description"
android:textColor="?secondaryText"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/infoBanner" />
<View
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@color/foundation_normal_theme"
android:importantForAccessibility="no"
app:layout_constraintTop_toBottomOf="@+id/handle" />
<ImageButton
android:id="@+id/exit_multi_select"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="0dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_tray_close_multiselect_content_description"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/multiselect_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/multiselect_title"
app:srcCompat="@drawable/ic_close"
app:tint="@color/contrast_text_normal_theme" />
<TextView
android:id="@+id/multiselect_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:focusableInTouchMode="true"
android:textColor="@color/contrast_text_normal_theme"
android:textSize="20sp"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintBottom_toBottomOf="@id/topBar"
app:layout_constraintEnd_toStartOf="@id/collect_multi_select"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@+id/exit_multi_select"
app:layout_constraintTop_toTopOf="@id/topBar"
tools:text="3 selected" />
<include layout="@layout/tabstray_multiselect_items" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="0dp"
android:layout_height="80dp"
android:background="@color/foundation_normal_theme"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/handle"
app:layout_constraintWidth_percent="0.5"
app:tabGravity="fill"
app:tabIconTint="@color/tab_icon"
app:tabIndicatorColor="@color/accent_normal_theme"
app:tabMaxWidth="0dp"
app:tabRippleColor="@android:color/transparent">
<com.google.android.material.tabs.TabItem
android:id="@+id/default_tab_item"
android:layout_width="0dp"
android:layout_height="match_parent"
android:contentDescription="@string/tab_header_label"
android:layout="@layout/tabs_tray_tab_counter"
app:tabIconTint="@color/tab_icon" />
<com.google.android.material.tabs.TabItem
android:id="@+id/private_tab_item"
android:layout_width="0dp"
android:layout_height="match_parent"
android:contentDescription="@string/tabs_header_private_tabs_title"
android:icon="@drawable/ic_private_browsing" />
</com.google.android.material.tabs.TabLayout>
<ImageButton
android:id="@+id/tab_tray_new_tab"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_tab"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/tab_layout"
app:layout_constraintEnd_toStartOf="@id/tab_tray_overflow"
app:layout_constraintTop_toTopOf="@id/tab_layout"
app:srcCompat="@drawable/ic_new"
app:tint="@color/primary_text_normal_theme" />
<ImageButton
android:id="@+id/tab_tray_overflow"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="0dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/open_tabs_menu"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@id/tab_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tab_layout"
app:srcCompat="@drawable/ic_menu"
app:tint="@color/tab_tray_heading_icon_menu_normal_theme" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/tab_tray_item_divider_normal_theme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/infoBanner" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tabsTray"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="140dp"
android:scrollbarStyle="outsideOverlay"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -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/. -->
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/NeutralButton"
android:layout_margin="8dp"
android:text="@string/tabs_tray_select_tabs" />

@ -113,11 +113,6 @@
<action
android:id="@+id/action_global_quickSettingsSheetDialogFragment"
app:destination="@id/quickSettingsSheetDialogFragment" />
<action
android:id="@+id/action_global_tabTrayDialogFragment"
app:destination="@id/tabTrayDialogFragment"
app:popUpTo="@id/tabTrayDialogFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_global_tabsTrayFragment"
app:destination="@id/tabsTrayFragment"
@ -145,16 +140,6 @@
app:argType="boolean" />
</dialog>
<dialog
android:id="@+id/tabTrayDialogFragment"
android:name="org.mozilla.fenix.tabtray.TabTrayDialogFragment"
tools:layout="@layout/fragment_tab_tray_dialog">
<argument
android:name="enterMultiselect"
android:defaultValue="false"
app:argType="boolean" />
</dialog>
<fragment
android:id="@+id/homeFragment"
android:name="org.mozilla.fenix.home.HomeFragment"

@ -264,7 +264,6 @@
<string name="pref_key_close_tabs_after_one_day" translatable="false">pref_key_close_tabs_after_one_day</string>
<string name="pref_key_close_tabs_after_one_week" translatable="false">pref_key_close_tabs_after_one_week</string>
<string name="pref_key_close_tabs_after_one_month" translatable="false">pref_key_close_tabs_after_one_month</string>
<string name="pref_key_new_tabs_tray" translatable="false">pref_key_new_tabs_tray</string>
<string name="pref_key_allow_third_party_root_certs" translatable="false">pref_key_allow_third_party_cert_roots</string>
<string name="pref_key_camera_permissions_needed" translatable="false">pref_key_camera_permissions_needed</string>

@ -37,10 +37,6 @@
<string name="preferences_debug_info" translatable="false">Secret Debug Info</string>
<!-- Label for enabling Address Autofill -->
<string name="preferences_debug_settings_enable_address_feature" translatable="false">Enable Address Autofill</string>
<!-- Label for the new tabs tray preference -->
<string name="preferences_debug_settings_tabs_tray_rewrite">Use new Tabs Tray</string>
<!-- Label for a longer description of the new tabs tray preference -->
<string name="preferences_debug_settings_tabs_tray_rewrite_summary">A refactored tabs tray that will include Synced Tabs.</string>
<!-- Label for allowing third party root certificates from the Android OS CA store preference -->
<string name="preferences_debug_settings_allow_third_party_root_certs">Use third party CA certificates</string>
<!-- Label for a longer description of allowing third party root certificates from the Android OS CA store preference -->

@ -533,8 +533,6 @@
<string name="library_new_tab">New tab</string>
<!-- Option in Library to find text in page -->
<string name="library_find_in_page">Find in page</string>
<!-- Option in Library to open Synced Tabs page -->
<string name="library_synced_tabs">Synced tabs</string>
<!-- Option in Library to open Reading List -->
<string name="library_reading_list">Reading List</string>
<!-- Menu Item Label for Search in Library -->

@ -9,12 +9,6 @@
android:key="@string/pref_key_show_address_feature"
android:title="@string/preferences_debug_settings_enable_address_feature"
app:iconSpaceReserved="false" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/pref_key_new_tabs_tray"
android:title="@string/preferences_debug_settings_tabs_tray_rewrite"
android:summary="@string/preferences_debug_settings_tabs_tray_rewrite_summary"
app:iconSpaceReserved="false" />
<SwitchPreference
android:defaultValue="false"
android:key="@string/pref_key_allow_third_party_root_certs"

@ -666,7 +666,7 @@ class DefaultSessionControlControllerTest {
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_tabTrayDialogFragment },
match<NavDirections> { it.actionId == R.id.action_global_tabsTrayFragment },
null
)
}

@ -35,7 +35,6 @@ import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.helpers.DisableNavGraphProviderAssertionRule
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentDirections
@RunWith(FenixRobolectricTestRunner::class)
class DefaultTabsTrayControllerTest {
@ -124,7 +123,7 @@ class DefaultTabsTrayControllerTest {
verifyOrder {
profiler.getProfilerTime()
navController.navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)
TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true)
)
navigationInteractor.onTabTrayDismissed()
profiler.addMarker(
@ -159,7 +158,7 @@ class DefaultTabsTrayControllerTest {
verifyOrder {
profiler.getProfilerTime()
navController.navigateBlockingForAsyncNavGraph(
TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)
TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true)
)
navigationInteractor.onTabTrayDismissed()
profiler.addMarker(

@ -271,7 +271,7 @@ class NavigationInteractorTest {
activity.openToBrowserAndLoad(
searchTermOrURL = "https://mozilla.org",
newTab = true,
from = BrowserDirection.FromTabTray
from = BrowserDirection.FromTabsTray
)
}
}

@ -1,318 +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.tabtray
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDirections
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.verify
import io.mockk.verifyAll
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.base.profiler.Profiler
import mozilla.components.concept.storage.BookmarksStorage
import mozilla.components.concept.tabstray.Tab
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.helpers.DisableNavGraphProviderAssertionRule
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTabTrayControllerTest {
private val activity: HomeActivity = mockk(relaxed = true)
private val profiler: Profiler? = mockk(relaxed = true)
private val navController: NavController = mockk()
private val browsingModeManager: BrowsingModeManager = mockk(relaxed = true)
private val dismissTabTray: (() -> Unit) = mockk(relaxed = true)
private val dismissTabTrayAndNavigateHome: ((String) -> Unit) = mockk(relaxed = true)
private val registerCollectionStorageObserver: (() -> Unit) = mockk(relaxed = true)
private val showChooseCollectionDialog: ((List<TabSessionState>) -> Unit) = mockk(relaxed = true)
private val showAddNewCollectionDialog: ((List<TabSessionState>) -> Unit) = mockk(relaxed = true)
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
private val bookmarksStorage: BookmarksStorage = mockk(relaxed = true)
private val tabCollection: TabCollection = mockk()
private val cachedTabCollections: List<TabCollection> = listOf(tabCollection)
private val currentDestination: NavDestination = mockk(relaxed = true)
private val tabTrayFragmentStore: TabTrayDialogFragmentStore = mockk(relaxed = true)
private val selectTabUseCase: TabsUseCases.SelectTabUseCase = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
private val showUndoSnackbarForTabs: (() -> Unit) = mockk(relaxed = true)
private val showBookmarksSavedSnackbar: (() -> Unit) = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private lateinit var controller: DefaultTabTrayController
private val tab1 = createTab(url = "http://firefox.com", id = "5678")
private val tab2 = createTab(url = "http://mozilla.org", id = "1234")
@get:Rule
val disableNavGraphProviderAssertionRule = DisableNavGraphProviderAssertionRule()
@Before
fun setUp() {
mockkStatic("org.mozilla.fenix.ext.SessionManagerKt")
val store = BrowserStore(
BrowserState(
tabs = listOf(tab1, tab2), selectedTabId = tab2.id
)
)
every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollections
every { navController.navigate(any<NavDirections>()) } just Runs
every { navController.currentDestination } returns currentDestination
every { currentDestination.id } returns R.id.browserFragment
every { tabCollection.title } returns "Collection title"
controller = DefaultTabTrayController(
activity = activity,
profiler = profiler,
browserStore = store,
browsingModeManager = browsingModeManager,
tabCollectionStorage = tabCollectionStorage,
bookmarksStorage = bookmarksStorage,
ioScope = TestCoroutineScope(),
metrics = metrics,
navController = navController,
tabsUseCases = tabsUseCases,
dismissTabTray = dismissTabTray,
dismissTabTrayAndNavigateHome = dismissTabTrayAndNavigateHome,
registerCollectionStorageObserver = registerCollectionStorageObserver,
tabTrayDialogFragmentStore = tabTrayFragmentStore,
selectTabUseCase = selectTabUseCase,
showChooseCollectionDialog = showChooseCollectionDialog,
showAddNewCollectionDialog = showAddNewCollectionDialog,
showUndoSnackbarForTabs = showUndoSnackbarForTabs,
showBookmarksSnackbar = showBookmarksSavedSnackbar
)
}
@Test
fun handleTabSettingsClicked() {
controller.handleTabSettingsClicked()
verify {
navController.navigate(
TabTrayDialogFragmentDirections.actionGlobalTabSettingsFragment()
)
}
}
@Test
fun onNewTabTapped() {
controller.handleNewTabTapped(private = false)
verifyOrder {
browsingModeManager.mode = BrowsingMode.fromBoolean(false)
navController.navigate(
TabTrayDialogFragmentDirections.actionGlobalHome(
focusOnAddressBar = true
)
)
dismissTabTray()
}
controller.handleNewTabTapped(private = true)
verifyOrder {
browsingModeManager.mode = BrowsingMode.fromBoolean(true)
navController.navigate(
TabTrayDialogFragmentDirections.actionGlobalHome(
focusOnAddressBar = true
)
)
dismissTabTray()
}
}
@Test
fun onTabTrayDismissed() {
controller.handleTabTrayDismissed()
verify {
dismissTabTray()
}
}
@Test
fun onShareTabsClicked() {
val navDirectionsSlot = slot<NavDirections>()
every { navController.navigate(capture(navDirectionsSlot)) } just Runs
controller.handleShareTabsOfTypeClicked(private = false)
verify {
navController.navigate(capture(navDirectionsSlot))
}
assertTrue(navDirectionsSlot.isCaptured)
assertEquals(R.id.action_global_shareFragment, navDirectionsSlot.captured.actionId)
}
@Test
fun onCloseAllTabsClicked() {
controller.handleCloseAllTabsClicked(private = false)
verify {
dismissTabTrayAndNavigateHome(any())
}
}
@Test
fun handleBackPressed() {
every { tabTrayFragmentStore.state.mode } returns TabTrayDialogFragmentState.Mode.MultiSelect(
setOf()
)
controller.handleBackPressed()
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
}
}
@Test
fun onModeRequested() {
val mode = TabTrayDialogFragmentState.Mode.MultiSelect(
setOf()
)
every { tabTrayFragmentStore.state.mode } returns mode
controller.onModeRequested()
verify {
tabTrayFragmentStore.state.mode
}
}
@Test
fun handleAddSelectedTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleAddSelectedTab(tab)
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.AddItemForCollection(tab))
}
}
@Test
fun handleRemoveSelectedTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleRemoveSelectedTab(tab)
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.RemoveItemForCollection(tab))
}
}
@Test
fun handleOpenTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleOpenTab(tab)
verify {
selectTabUseCase.invoke(tab.id)
}
}
@Test
fun handleEnterMultiselect() {
controller.handleEnterMultiselect()
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.EnterMultiSelectMode)
}
}
@Test
fun onSaveToCollectionClicked() {
val tab = Tab(tab2.id, tab2.content.url)
controller.handleSaveToCollectionClicked(setOf(tab))
verifyAll {
metrics.track(Event.TabsTraySaveToCollectionPressed)
registerCollectionStorageObserver()
showChooseCollectionDialog(listOf(tab2))
}
}
@Test
fun handleShareSelectedTabs() {
val tab = Tab("1234", "mozilla.org")
val navDirectionsSlot = slot<NavDirections>()
every { navController.navigate(capture(navDirectionsSlot)) } just Runs
controller.handleShareSelectedTabsClicked(setOf(tab))
verify {
navController.navigate(capture(navDirectionsSlot))
}
assertTrue(navDirectionsSlot.isCaptured)
assertEquals(R.id.action_global_shareFragment, navDirectionsSlot.captured.actionId)
}
@Test
fun handleDeleteSelectedTabs() {
val tab = Tab("1234", "mozilla.org")
controller.handleDeleteSelectedTabs(setOf(tab))
verifyAll {
tabsUseCases.removeTabs(listOf(tab.id))
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
showUndoSnackbarForTabs()
}
}
@Test
fun handleBookmarkSelectedTabs() {
val tab = Tab("1234", "mozilla.org")
coEvery { bookmarksStorage.getBookmarksWithUrl("mozilla.org") } returns listOf()
controller.handleBookmarkSelectedTabs(setOf(tab))
verifyAll {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
showBookmarksSavedSnackbar()
}
}
@Test
fun handleRecentlyClosedClicked() {
controller.handleRecentlyClosedClicked()
val directions = TabTrayDialogFragmentDirections.actionGlobalRecentlyClosed()
verifyAll {
navController.navigate(directions)
metrics.track(Event.RecentlyClosedTabsOpened)
}
}
@Test
fun handleGoToTabsSettingClicked() {
controller.handleGoToTabsSettingClicked()
val directions = TabTrayDialogFragmentDirections.actionGlobalTabSettingsFragment()
verifyAll {
navController.navigate(directions)
metrics.track(Event.TabsTrayCfrTapped)
}
}
}

@ -1,53 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.tabtray
import android.widget.FrameLayout
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.Item
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.ViewHolder
import kotlin.random.Random
@RunWith(FenixRobolectricTestRunner::class)
class SaveToCollectionsButtonAdapterTest {
private lateinit var adapter: SaveToCollectionsButtonAdapter
private lateinit var interactor: TabTrayInteractor
@Before
fun setup() {
interactor = mockk(relaxed = true)
adapter = SaveToCollectionsButtonAdapter(interactor)
}
@Test
fun `create adapter only has one item in it`() {
assertEquals(1, adapter.itemCount)
assertTrue(adapter.currentList.first() is Item)
}
@Test
fun `viewholder click invokes interactor`() {
val itemView = FrameLayout(testContext)
val viewHolder = ViewHolder(itemView, interactor)
viewHolder.onClick(itemView)
verify { interactor.onEnterMultiselect() }
}
@Test
fun `always use the same layout`() {
assertEquals(ViewHolder.LAYOUT_ID, adapter.getItemViewType(Random.nextInt()))
}
}

@ -1,145 +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.tabtray
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.concept.tabstray.Tab
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class TabTrayDialogFragmentStoreTest {
@Test
fun browserStateChange() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
val newBrowserState = BrowserState(
listOf(
createTab("https://www.mozilla.org", id = "13256")
)
)
store.dispatch(
TabTrayDialogFragmentAction.BrowserStateChanged(
newBrowserState
)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.browserState,
newBrowserState
)
}
@Test
fun enterMultiselectMode() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.EnterMultiSelectMode
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
)
}
@Test
fun exitMultiselectMode() = runBlocking {
val initialState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
)
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.ExitMultiSelectMode
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.Normal
)
assertEquals(
store.state.mode.selectedItems,
setOf<Tab>()
)
}
@Test
fun addItemForCollection() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
val tab = Tab(id = "1234", url = "mozilla.org")
store.dispatch(
TabTrayDialogFragmentAction.AddItemForCollection(tab)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab))
)
assertEquals(
store.state.mode.selectedItems,
setOf(tab)
)
}
@Test
fun removeItemForCollection() = runBlocking {
val tab = Tab(id = "1234", url = "mozilla.org")
val secondTab = Tab(id = "12345", url = "pocket.com")
val initialState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab, secondTab))
)
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.RemoveItemForCollection(tab)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(secondTab))
)
assertEquals(
store.state.mode.selectedItems,
setOf(secondTab)
)
store.dispatch(
TabTrayDialogFragmentAction.RemoveItemForCollection(secondTab)
).join()
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.Normal
)
assertEquals(
store.state.mode.selectedItems,
setOf<Tab>()
)
}
private fun emptyDefaultState(): TabTrayDialogFragmentState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.Normal
)
}

@ -1,136 +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.tabtray
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.concept.tabstray.Tab
import org.junit.Test
class TabTrayFragmentInteractorTest {
private val controller = mockk<TabTrayController>(relaxed = true)
private val interactor = TabTrayFragmentInteractor(controller)
@Test
fun onShareSelectedTabsClicked() {
val tab = Tab("1234", "mozilla.org")
val tab2 = Tab("5678", "pocket.com")
val selectedTabs = setOf(tab, tab2)
interactor.onShareSelectedTabsClicked(selectedTabs)
verify { controller.handleShareSelectedTabsClicked(selectedTabs) }
}
@Test
fun onBookmarkSelectedTabs() {
val tab = Tab("1234", "mozilla.org")
val tab2 = Tab("5678", "pocket.com")
val selectedTabs = setOf(tab, tab2)
interactor.onBookmarkSelectedTabs(selectedTabs)
verify { controller.handleBookmarkSelectedTabs(selectedTabs) }
}
@Test
fun onDeleteSelectedTabs() {
val tab = Tab("1234", "mozilla.org")
val tab2 = Tab("5678", "pocket.com")
val selectedTabs = setOf(tab, tab2)
interactor.onDeleteSelectedTabs(selectedTabs)
verify { controller.handleDeleteSelectedTabs(selectedTabs) }
}
@Test
fun onNewTabTapped() {
interactor.onNewTabTapped(private = true)
verify { controller.handleNewTabTapped(true) }
interactor.onNewTabTapped(private = false)
verify { controller.handleNewTabTapped(false) }
}
@Test
fun onTabSettingsClicked() {
interactor.onTabSettingsClicked()
verify {
controller.handleTabSettingsClicked()
}
}
@Test
fun onTabTrayDismissed() {
interactor.onTabTrayDismissed()
verify { controller.handleTabTrayDismissed() }
}
@Test
fun onShareTabsClicked() {
interactor.onShareTabsOfTypeClicked(private = true)
verify { controller.handleShareTabsOfTypeClicked(true) }
interactor.onShareTabsOfTypeClicked(private = false)
verify { controller.handleShareTabsOfTypeClicked(false) }
}
@Test
fun onSaveToCollectionClicked() {
val tab = Tab("1234", "mozilla.org")
interactor.onSaveToCollectionClicked(setOf(tab))
verify { controller.handleSaveToCollectionClicked(setOf(tab)) }
}
@Test
fun onCloseAllTabsClicked() {
interactor.onCloseAllTabsClicked(private = false)
verify { controller.handleCloseAllTabsClicked(false) }
interactor.onCloseAllTabsClicked(private = true)
verify { controller.handleCloseAllTabsClicked(true) }
}
@Test
fun onBackPressed() {
interactor.onBackPressed()
verify { controller.handleBackPressed() }
}
@Test
fun onModeRequested() {
interactor.onModeRequested()
verify { controller.onModeRequested() }
}
@Test
fun onOpenTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onOpenTab(tab)
verify { controller.handleOpenTab(tab) }
}
@Test
fun onAddSelectedTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onAddSelectedTab(tab)
verify { controller.handleAddSelectedTab(tab) }
}
@Test
fun onRemoveSelectedTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onRemoveSelectedTab(tab)
verify { controller.handleRemoveSelectedTab(tab) }
}
@Test
fun onEnterMultiselect() {
interactor.onEnterMultiselect()
verify { controller.handleEnterMultiselect() }
}
@Test
fun onGoToTabsSettingClicked() {
interactor.onGoToTabsSettings()
verify { controller.handleGoToTabsSettingClicked() }
}
}

@ -1,140 +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.tabtray
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageButton
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.MediaSessionState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.base.images.ImageLoadRequest
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.engine.mediasession.MediaSession
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class TabTrayViewHolderTest {
private lateinit var view: View
@MockK private lateinit var imageLoader: ImageLoader
@MockK private lateinit var store: BrowserStore
@MockK private lateinit var sessionState: SessionState
@MockK private lateinit var mediaSessionState: MediaSessionState
@MockK private lateinit var metrics: MetricController
private var state = BrowserState()
@Before
fun setup() {
MockKAnnotations.init(this)
view = LayoutInflater.from(testContext)
.inflate(R.layout.tab_tray_item, null, false)
state = BrowserState()
every { imageLoader.loadIntoView(any(), any(), any(), any()) } just Runs
every { store.state } answers { state }
}
@Test
fun `extremely long URLs are truncated to prevent slowing down the UI`() {
val tabViewHolder = createViewHolder()
val extremelyLongUrl = "m".repeat(MAX_URI_LENGTH + 1)
val tab = Tab(
id = "123",
url = extremelyLongUrl
)
tabViewHolder.bind(tab, false, mockk(), mockk())
assertEquals("m".repeat(MAX_URI_LENGTH), tabViewHolder.urlView?.text)
verify { imageLoader.loadIntoView(any(), ImageLoadRequest("123", 92)) }
}
@Test
fun `show play button if media is paused in tab`() {
val playPauseButtonView: ImageButton = view.findViewById(R.id.play_pause_button)
val tabViewHolder = createViewHolder()
val tab = Tab(
id = "123",
url = "https://example.com"
)
state = state.copy(
tabs = listOf(
TabSessionState(
id = "123",
content = ContentState(
url = "https://example.com",
searchTerms = "search terms"
),
mediaSessionState = mediaSessionState
)
)
)
every { mediaSessionState.playbackState } answers { MediaSession.PlaybackState.PAUSED }
tabViewHolder.bind(tab, false, mockk(), mockk())
assertEquals("Play", playPauseButtonView.contentDescription)
}
@Test
fun `show pause button if media is playing in tab`() {
val playPauseButtonView: ImageButton = view.findViewById(R.id.play_pause_button)
val tabViewHolder = createViewHolder()
val tab = Tab(
id = "123",
url = "https://example.com"
)
state = state.copy(
tabs = listOf(
TabSessionState(
id = "123",
content = ContentState(
url = "https://example.com",
searchTerms = "search terms"
),
mediaSessionState = mediaSessionState
)
)
)
every { mediaSessionState.playbackState } answers { MediaSession.PlaybackState.PLAYING }
tabViewHolder.bind(tab, false, mockk(), mockk())
assertEquals("Pause", playPauseButtonView.contentDescription)
}
private fun createViewHolder() = TabTrayViewHolder(
view,
imageLoader = imageLoader,
store = store,
metrics = metrics
)
}

@ -1,55 +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.tabtray
import android.widget.FrameLayout
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
import androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags
import androidx.recyclerview.widget.RecyclerView
import io.mockk.mockk
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.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class TabsTouchHelperTest {
@Test
fun `movement flags remain unchanged if onSwipeToDelete is true`() {
val recyclerView = RecyclerView(testContext)
val layout = FrameLayout(testContext)
val interactor: TabTrayInteractor = mockk(relaxed = true)
val viewHolder = SaveToCollectionsButtonAdapter.ViewHolder(layout, interactor)
val callback = TouchCallback(mockk()) { true }
assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
assertEquals(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, callback.getSwipeDirs(recyclerView, viewHolder))
val actual = callback.getMovementFlags(recyclerView, viewHolder)
val expected = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)
assertEquals(expected, actual)
}
@Test
fun `movement flags remain unchanged if onSwipeToDelete is false`() {
val recyclerView = RecyclerView(testContext)
val layout = FrameLayout(testContext)
val interactor: TabTrayInteractor = mockk(relaxed = true)
val viewHolder = SaveToCollectionsButtonAdapter.ViewHolder(layout, interactor)
val callback = TouchCallback(mockk()) { false }
assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
assertEquals(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, callback.getSwipeDirs(recyclerView, viewHolder))
val actual = callback.getMovementFlags(recyclerView, viewHolder)
val expected = ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0)
assertEquals(expected, actual)
}
}
Loading…
Cancel
Save