fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt

1125 lines
46 KiB
Kotlin

/* 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.home
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.os.StrictMode
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Button
import android.widget.LinearLayout
import android.widget.PopupWindow
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.constraintlayout.widget.ConstraintSet.BOTTOM
import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID
import androidx.constraintlayout.widget.ConstraintSet.TOP
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.button.MaterialButton
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.menu.view.MenuButton
import mozilla.components.browser.state.selector.findTab
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.selectedOrDefaultSearchEngine
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.storage.FrecencyThresholdOption
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSitesConfig
import mozilla.components.feature.top.sites.TopSitesFeature
import mozilla.components.feature.top.sites.TopSitesProviderConfig
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.service.glean.private.NoExtras
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import mozilla.components.ui.tabcounter.TabCounterMenu
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.Config
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.HomeScreen
import org.mozilla.fenix.GleanMetrics.StartOnHome
import org.mozilla.fenix.GleanMetrics.Wallpapers
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions
import org.mozilla.fenix.browser.BrowserFragmentDirections
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.PrivateShortcutCreateManager
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.accounts.AccountState
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.databinding.FragmentHomeBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.DefaultMessageController
import org.mozilla.fenix.gleanplumb.MessagingFeature
import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow
import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.recentbookmarks.RecentBookmarksFeature
import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksController
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabFeature
import org.mozilla.fenix.home.recentsyncedtabs.controller.DefaultRecentSyncedTabController
import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature
import org.mozilla.fenix.home.recenttabs.controller.DefaultRecentTabsController
import org.mozilla.fenix.home.recentvisits.RecentVisitsFeature
import org.mozilla.fenix.home.recentvisits.controller.DefaultRecentVisitsController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView
import org.mozilla.fenix.home.topsites.DefaultTopSitesView
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.perf.MarkersFragmentLifecycleCallbacks
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
import org.mozilla.fenix.tabstray.TabsTrayAccessPoint
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.Settings.Companion.TOP_SITES_PROVIDER_MAX_THRESHOLD
import org.mozilla.fenix.utils.ToolbarPopupWindow
import org.mozilla.fenix.utils.allowUndo
import org.mozilla.fenix.wallpapers.WallpaperManager
import org.mozilla.fenix.whatsnew.WhatsNew
import java.lang.ref.WeakReference
import kotlin.math.min
import org.mozilla.fenix.GleanMetrics.HomeMenu as HomeMenuMetrics
@Suppress("TooManyFunctions", "LargeClass")
class HomeFragment : Fragment() {
private val args by navArgs<HomeFragmentArgs>()
private lateinit var bundleArgs: Bundle
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeScreenViewModel by activityViewModels()
private val snackbarAnchorView: View?
get() = when (requireContext().settings().toolbarPosition) {
ToolbarPosition.BOTTOM -> binding.toolbarLayout
ToolbarPosition.TOP -> null
}
private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager
private val collectionStorageObserver = object : TabCollectionStorage.Observer {
@SuppressLint("NotifyDataSetChanged")
override fun onCollectionRenamed(tabCollection: TabCollection, title: String) {
lifecycleScope.launch(Main) {
binding.sessionControlRecyclerView.adapter?.notifyDataSetChanged()
}
showRenamedSnackbar()
}
}
private val store: BrowserStore
get() = requireComponents.core.store
private val onboarding by lazy {
requireComponents.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
FenixOnboarding(requireContext())
}
}
private val syncedTabFeature by lazy {
RecentSyncedTabFeature(
store = requireComponents.appStore,
context = requireContext(),
storage = requireComponents.backgroundServices.syncedTabsStorage,
accountManager = requireComponents.backgroundServices.accountManager,
lifecycleOwner = viewLifecycleOwner,
)
}
private var _sessionControlInteractor: SessionControlInteractor? = null
private val sessionControlInteractor: SessionControlInteractor
get() = _sessionControlInteractor!!
private var sessionControlView: SessionControlView? = null
private var appBarLayout: AppBarLayout? = null
private lateinit var currentMode: CurrentMode
private val topSitesFeature = ViewBoundFeatureWrapper<TopSitesFeature>()
private val messagingFeature = ViewBoundFeatureWrapper<MessagingFeature>()
private val recentTabsListFeature = ViewBoundFeatureWrapper<RecentTabsListFeature>()
private val recentSyncedTabFeature = ViewBoundFeatureWrapper<RecentSyncedTabFeature>()
private val recentBookmarksFeature = ViewBoundFeatureWrapper<RecentBookmarksFeature>()
private val historyMetadataFeature = ViewBoundFeatureWrapper<RecentVisitsFeature>()
@VisibleForTesting
internal var getMenuButton: () -> MenuButton? = { binding.menuButton }
override fun onCreate(savedInstanceState: Bundle?) {
// DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
super.onCreate(savedInstanceState)
bundleArgs = args.toBundle()
if (!onboarding.userHasBeenOnboarded() &&
requireContext().settings().shouldShowPrivacyPopWindow &&
Config.channel.isMozillaOnline
) {
showPrivacyPopWindow(requireContext(), requireActivity())
}
// DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
requireComponents.core.engine.profiler?.addMarker(
MarkersFragmentLifecycleCallbacks.MARKER_NAME, profilerStartTime, "HomeFragment.onCreate",
)
}
@Suppress("LongMethod")
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
_binding = FragmentHomeBinding.inflate(inflater, container, false)
val activity = activity as HomeActivity
val components = requireComponents
currentMode = CurrentMode(
requireContext(),
onboarding,
browsingModeManager,
::dispatchModeChanges
)
components.appStore.dispatch(AppAction.ModeChange(currentMode.getCurrentMode()))
lifecycleScope.launch(IO) {
if (requireContext().settings().showPocketRecommendationsFeature) {
val categories = components.core.pocketStoriesService.getStories()
.groupBy { story -> story.category }
.map { (category, stories) -> PocketRecommendedStoriesCategory(category, stories) }
components.appStore.dispatch(AppAction.PocketStoriesCategoriesChange(categories))
} else {
components.appStore.dispatch(AppAction.PocketStoriesChange(emptyList()))
}
}
if (requireContext().settings().isExperimentationEnabled) {
messagingFeature.set(
feature = MessagingFeature(
store = requireComponents.appStore,
),
owner = viewLifecycleOwner,
view = binding.root
)
}
if (requireContext().settings().showTopSitesFeature) {
topSitesFeature.set(
feature = TopSitesFeature(
view = DefaultTopSitesView(
store = components.appStore,
settings = components.settings
),
storage = components.core.topSitesStorage,
config = ::getTopSitesConfig
),
owner = viewLifecycleOwner,
view = binding.root
)
}
if (requireContext().settings().showRecentTabsFeature) {
recentTabsListFeature.set(
feature = RecentTabsListFeature(
browserStore = components.core.store,
appStore = components.appStore
),
owner = viewLifecycleOwner,
view = binding.root
)
if (FeatureFlags.taskContinuityFeature) {
recentSyncedTabFeature.set(
feature = syncedTabFeature,
owner = viewLifecycleOwner,
view = binding.root
)
}
}
if (requireContext().settings().showRecentBookmarksFeature) {
recentBookmarksFeature.set(
feature = RecentBookmarksFeature(
appStore = components.appStore,
bookmarksUseCase = run {
requireContext().components.useCases.bookmarksUseCases
},
scope = viewLifecycleOwner.lifecycleScope
),
owner = viewLifecycleOwner,
view = binding.root
)
}
if (requireContext().settings().historyMetadataUIFeature) {
historyMetadataFeature.set(
feature = RecentVisitsFeature(
appStore = components.appStore,
historyMetadataStorage = components.core.historyStorage,
historyHighlightsStorage = components.core.lazyHistoryStorage,
scope = viewLifecycleOwner.lifecycleScope
),
owner = viewLifecycleOwner,
view = binding.root
)
}
_sessionControlInteractor = SessionControlInteractor(
controller = DefaultSessionControlController(
activity = activity,
settings = components.settings,
engine = components.core.engine,
messageController = DefaultMessageController(
appStore = components.appStore,
messagingStorage = components.analytics.messagingStorage,
homeActivity = activity,
),
store = store,
tabCollectionStorage = components.core.tabCollectionStorage,
addTabUseCase = components.useCases.tabsUseCases.addTab,
restoreUseCase = components.useCases.tabsUseCases.restore,
reloadUrlUseCase = components.useCases.sessionUseCases.reload,
selectTabUseCase = components.useCases.tabsUseCases.selectTab,
appStore = components.appStore,
navController = findNavController(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
hideOnboarding = ::hideOnboardingAndOpenSearch,
registerCollectionStorageObserver = ::registerCollectionStorageObserver,
removeCollectionWithUndo = ::removeCollectionWithUndo,
showTabTray = ::openTabsTray
),
recentTabController = DefaultRecentTabsController(
selectTabUseCase = components.useCases.tabsUseCases.selectTab,
navController = findNavController(),
store = components.core.store,
appStore = components.appStore,
),
recentSyncedTabController = DefaultRecentSyncedTabController(
addNewTabUseCase = requireComponents.useCases.tabsUseCases.addTab,
navController = findNavController(),
accessPoint = TabsTrayAccessPoint.HomeRecentSyncedTab,
),
recentBookmarksController = DefaultRecentBookmarksController(
activity = activity,
navController = findNavController(),
appStore = components.appStore,
),
recentVisitsController = DefaultRecentVisitsController(
navController = findNavController(),
appStore = components.appStore,
selectOrAddTabUseCase = components.useCases.tabsUseCases.selectOrAddTab,
storage = components.core.historyStorage,
scope = viewLifecycleOwner.lifecycleScope,
store = components.core.store,
),
pocketStoriesController = DefaultPocketStoriesController(
homeActivity = activity,
appStore = components.appStore,
navController = findNavController(),
)
)
updateLayout(binding.root)
sessionControlView = SessionControlView(
binding.sessionControlRecyclerView,
viewLifecycleOwner,
sessionControlInteractor
)
updateSessionControlView()
appBarLayout = binding.homeAppBar
activity.themeManager.applyStatusBarTheme(activity)
FxNimbus.features.homescreen.recordExposure()
displayWallpaperIfEnabled()
// DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
requireComponents.core.engine.profiler?.addMarker(
MarkersFragmentLifecycleCallbacks.MARKER_NAME, profilerStartTime, "HomeFragment.onCreateView",
)
return binding.root
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
getMenuButton()?.dismissMenu()
displayWallpaperIfEnabled()
}
/**
* Returns a [TopSitesConfig] which specifies how many top sites to display and whether or
* not frequently visited sites should be displayed.
*/
@VisibleForTesting
internal fun getTopSitesConfig(): TopSitesConfig {
val settings = requireContext().settings()
return TopSitesConfig(
totalSites = settings.topSitesMaxLimit,
frecencyConfig = FrecencyThresholdOption.SKIP_ONE_TIME_PAGES,
providerConfig = TopSitesProviderConfig(
showProviderTopSites = settings.showContileFeature,
maxThreshold = TOP_SITES_PROVIDER_MAX_THRESHOLD,
providerFilter = { topSite ->
when (store.state.search.selectedOrDefaultSearchEngine?.name) {
AMAZON_SEARCH_ENGINE_NAME -> topSite.title != AMAZON_SPONSORED_TITLE
EBAY_SPONSORED_TITLE -> topSite.title != EBAY_SPONSORED_TITLE
else -> true
}
}
)
)
}
/**
* The [SessionControlView] is forced to update with our current state when we call
* [HomeFragment.onCreateView] in order to be able to draw everything at once with the current
* data in our store. The [View.consumeFrom] coroutine dispatch
* doesn't get run right away which means that we won't draw on the first layout pass.
*/
private fun updateSessionControlView() {
if (browsingModeManager.mode == BrowsingMode.Private) {
binding.root.consumeFrom(requireContext().components.appStore, viewLifecycleOwner) {
sessionControlView?.update(it)
}
} else {
sessionControlView?.update(requireContext().components.appStore.state)
binding.root.consumeFrom(requireContext().components.appStore, viewLifecycleOwner) {
sessionControlView?.update(it, shouldReportMetrics = true)
}
}
}
private fun updateLayout(view: View) {
when (requireContext().settings().toolbarPosition) {
ToolbarPosition.TOP -> {
binding.toolbarLayout.layoutParams = CoordinatorLayout.LayoutParams(
ConstraintLayout.LayoutParams.MATCH_PARENT,
ConstraintLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP
}
ConstraintSet().apply {
clone(binding.toolbarLayout)
clear(binding.bottomBar.id, BOTTOM)
clear(binding.bottomBarShadow.id, BOTTOM)
connect(binding.bottomBar.id, TOP, PARENT_ID, TOP)
connect(binding.bottomBarShadow.id, TOP, binding.bottomBar.id, BOTTOM)
connect(binding.bottomBarShadow.id, BOTTOM, PARENT_ID, BOTTOM)
applyTo(binding.toolbarLayout)
}
binding.bottomBar.background = AppCompatResources.getDrawable(
view.context,
view.context.theme.resolveAttribute(R.attr.bottomBarBackgroundTop)
)
binding.homeAppBar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin =
resources.getDimensionPixelSize(R.dimen.home_fragment_top_toolbar_header_margin)
}
}
ToolbarPosition.BOTTOM -> {
}
}
}
@Suppress("LongMethod", "ComplexMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
super.onViewCreated(view, savedInstanceState)
HomeScreen.homeScreenDisplayed.record(NoExtras())
HomeScreen.homeScreenViewCount.add()
observeSearchEngineChanges()
observeSearchEngineNameChanges()
createHomeMenu(requireContext(), WeakReference(binding.menuButton))
createTabCounterMenu()
binding.menuButton.setColorFilter(
ContextCompat.getColor(
requireContext(),
ThemeManager.resolveAttribute(R.attr.textPrimary, requireContext())
)
)
binding.toolbar.compoundDrawablePadding =
view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding)
binding.toolbarWrapper.setOnClickListener {
navigateToSearch()
}
binding.toolbarWrapper.setOnLongClickListener {
ToolbarPopupWindow.show(
WeakReference(it),
handlePasteAndGo = sessionControlInteractor::onPasteAndGo,
handlePaste = sessionControlInteractor::onPaste,
copyVisible = false
)
true
}
binding.tabButton.setOnClickListener {
StartOnHome.openTabsTray.record(NoExtras())
openTabsTray()
}
PrivateBrowsingButtonView(binding.privateBrowsingButton, browsingModeManager) { newMode ->
sessionControlInteractor.onPrivateModeButtonClicked(
newMode,
onboarding.userHasBeenOnboarded()
)
}
consumeFrom(requireComponents.core.store) {
updateTabCounter(it)
}
homeViewModel.sessionToDelete?.also {
if (it == ALL_NORMAL_TABS || it == ALL_PRIVATE_TABS) {
removeAllTabsAndShowSnackbar(it)
} else {
removeTabAndShowSnackbar(it)
}
}
homeViewModel.sessionToDelete = null
updateTabCounter(requireComponents.core.store.state)
if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) {
navigateToSearch()
}
// DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
requireComponents.core.engine.profiler?.addMarker(
MarkersFragmentLifecycleCallbacks.MARKER_NAME, profilerStartTime, "HomeFragment.onViewCreated",
)
}
private fun observeSearchEngineChanges() {
consumeFlow(store) { flow ->
flow.map { state -> state.search.selectedOrDefaultSearchEngine }
.ifChanged()
.collect { searchEngine ->
if (searchEngine != null) {
val iconSize =
requireContext().resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
val searchIcon =
BitmapDrawable(requireContext().resources, searchEngine.icon)
searchIcon.setBounds(0, 0, iconSize, iconSize)
binding.searchEngineIcon.setImageDrawable(searchIcon)
} else {
binding.searchEngineIcon.setImageDrawable(null)
}
}
}
}
/**
* Method used to listen to search engine name changes and trigger a top sites update accordingly
*/
private fun observeSearchEngineNameChanges() {
consumeFlow(store) { flow ->
flow.map { state ->
when (state.search.selectedOrDefaultSearchEngine?.name) {
AMAZON_SEARCH_ENGINE_NAME -> AMAZON_SPONSORED_TITLE
EBAY_SPONSORED_TITLE -> EBAY_SPONSORED_TITLE
else -> null
}
}
.ifChanged()
.collect {
topSitesFeature.withFeature {
it.storage.notifyObservers { onStorageUpdated() }
}
}
}
}
private fun createTabCounterMenu() {
val browsingModeManager = (activity as HomeActivity).browsingModeManager
val mode = browsingModeManager.mode
val onItemTapped: (TabCounterMenu.Item) -> Unit = {
if (it is TabCounterMenu.Item.NewTab) {
browsingModeManager.mode = BrowsingMode.Normal
} else if (it is TabCounterMenu.Item.NewPrivateTab) {
browsingModeManager.mode = BrowsingMode.Private
}
}
val tabCounterMenu = FenixTabCounterMenu(
requireContext(),
onItemTapped,
iconColor = if (mode == BrowsingMode.Private) {
ContextCompat.getColor(requireContext(), R.color.fx_mobile_private_text_color_primary)
} else {
null
}
)
val inverseBrowsingMode = when (mode) {
BrowsingMode.Normal -> BrowsingMode.Private
BrowsingMode.Private -> BrowsingMode.Normal
}
tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode)
binding.tabButton.setOnLongClickListener {
tabCounterMenu.menuController.show(anchor = it)
true
}
}
private fun removeAllTabsAndShowSnackbar(sessionCode: String) {
if (sessionCode == ALL_PRIVATE_TABS) {
requireComponents.useCases.tabsUseCases.removePrivateTabs()
} else {
requireComponents.useCases.tabsUseCases.removeNormalTabs()
}
val snackbarMessage = if (sessionCode == ALL_PRIVATE_TABS) {
getString(R.string.snackbar_private_tabs_closed)
} else {
getString(R.string.snackbar_tabs_closed)
}
viewLifecycleOwner.lifecycleScope.allowUndo(
requireView(),
snackbarMessage,
requireContext().getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
},
operation = { },
anchorView = snackbarAnchorView
)
}
private fun removeTabAndShowSnackbar(sessionId: String) {
val tab = store.state.findTab(sessionId) ?: return
requireComponents.useCases.tabsUseCases.removeTab(sessionId)
val snackbarMessage = if (tab.content.private) {
requireContext().getString(R.string.snackbar_private_tab_closed)
} else {
requireContext().getString(R.string.snackbar_tab_closed)
}
viewLifecycleOwner.lifecycleScope.allowUndo(
requireView(),
snackbarMessage,
requireContext().getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
findNavController().navigate(
HomeFragmentDirections.actionGlobalBrowser(null)
)
},
operation = { },
anchorView = snackbarAnchorView
)
}
override fun onDestroyView() {
super.onDestroyView()
_sessionControlInteractor = null
sessionControlView = null
appBarLayout = null
_binding = null
bundleArgs.clear()
}
override fun onStart() {
super.onStart()
subscribeToTabCollections()
val context = requireContext()
requireComponents.backgroundServices.accountManagerAvailableQueue.runIfReadyOrQueue {
// By the time this code runs, we may not be attached to a context or have a view lifecycle owner.
if ((this@HomeFragment).view?.context == null) {
return@runIfReadyOrQueue
}
requireComponents.backgroundServices.accountManager.register(
currentMode,
owner = this@HomeFragment.viewLifecycleOwner
)
requireComponents.backgroundServices.accountManager.register(
object : AccountObserver {
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
if (authType != AuthType.Existing) {
view?.let {
FenixSnackbar.make(
view = it,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false
)
.setText(it.context.getString(R.string.onboarding_firefox_account_sync_is_on))
.setAnchorView(binding.toolbarLayout)
.show()
}
}
}
},
owner = this@HomeFragment.viewLifecycleOwner
)
}
if (browsingModeManager.mode.isPrivate &&
// We will be showing the search dialog and don't want to show the CFR while the dialog shows
!bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR) &&
context.settings().shouldShowPrivateModeCfr
) {
recommendPrivateBrowsingShortcut()
}
// We only want this observer live just before we navigate away to the collection creation screen
requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver)
lifecycleScope.launch(IO) {
requireComponents.reviewPromptController.promptReview(requireActivity())
}
if (shouldEnableWallpaper() && context.settings().wallpapersSwitchedByLogoTap) {
binding.wordmark.contentDescription =
context.getString(R.string.wallpaper_logo_content_description)
binding.wordmark.setOnClickListener {
val manager = requireComponents.wallpaperManager
val newWallpaper = manager.switchToNextWallpaper()
Wallpapers.wallpaperSwitched.record(
Wallpapers.WallpaperSwitchedExtra(
name = newWallpaper.name,
themeCollection = newWallpaper::class.simpleName
)
)
manager.updateWallpaper(
wallpaperContainer = binding.wallpaperImageView,
newWallpaper = newWallpaper
)
}
}
}
private fun dispatchModeChanges(mode: Mode) {
if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) {
requireContext().components.appStore.dispatch(AppAction.ModeChange(mode))
}
}
@VisibleForTesting
internal fun removeCollectionWithUndo(tabCollection: TabCollection) {
val snackbarMessage = getString(R.string.snackbar_collection_deleted)
lifecycleScope.allowUndo(
requireView(),
snackbarMessage,
getString(R.string.snackbar_deleted_undo),
{
requireComponents.core.tabCollectionStorage.createCollection(tabCollection)
},
operation = { },
elevation = TOAST_ELEVATION,
anchorView = null
)
lifecycleScope.launch(IO) {
requireComponents.core.tabCollectionStorage.removeCollection(tabCollection)
}
}
override fun onResume() {
super.onResume()
if (browsingModeManager.mode == BrowsingMode.Private) {
activity?.window?.setBackgroundDrawableResource(R.drawable.private_home_background_gradient)
}
hideToolbar()
// Whenever a tab is selected its last access timestamp is automatically updated by A-C.
// However, in the case of resuming the app to the home fragment, we already have an
// existing selected tab, but its last access timestamp is outdated. No action is
// triggered to cause an automatic update on warm start (no tab selection occurs). So we
// update it manually here.
requireComponents.useCases.sessionUseCases.updateLastAccess()
if (shouldAnimateLogoForWallpaper()) {
_binding?.sessionControlRecyclerView?.viewTreeObserver?.addOnGlobalLayoutListener(
homeLayoutListenerForLogoAnimation
)
}
}
// To try to find a good time to show the logo animation, we are waiting until all
// the sub-recyclerviews (recentBookmarks, collections, recentTabs,recentVisits
// and pocketStories) on the home screen have been layout.
private val homeLayoutListenerForLogoAnimation = object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
_binding?.let { safeBindings ->
requireComponents.wallpaperManager.animateLogoIfNeeded(safeBindings.wordmark)
safeBindings.sessionControlRecyclerView.viewTreeObserver.removeOnGlobalLayoutListener(
this
)
}
}
}
override fun onPause() {
super.onPause()
if (browsingModeManager.mode == BrowsingMode.Private) {
activity?.window?.setBackgroundDrawable(
ColorDrawable(
ContextCompat.getColor(
requireContext(),
R.color.fx_mobile_private_layer_color_1
)
)
)
}
// Counterpart to the update in onResume to keep the last access timestamp of the selected
// tab up-to-date.
requireComponents.useCases.sessionUseCases.updateLastAccess()
}
@SuppressLint("InflateParams")
private fun recommendPrivateBrowsingShortcut() {
context?.let { context ->
val layout = LayoutInflater.from(context)
.inflate(R.layout.pbm_shortcut_popup, null)
val privateBrowsingRecommend =
PopupWindow(
layout,
min(
(resources.displayMetrics.widthPixels / CFR_WIDTH_DIVIDER).toInt(),
(resources.displayMetrics.heightPixels / CFR_WIDTH_DIVIDER).toInt()
),
LinearLayout.LayoutParams.WRAP_CONTENT,
true
)
layout.findViewById<Button>(R.id.cfr_pos_button).apply {
setOnClickListener {
PrivateShortcutCreateManager.createPrivateShortcut(context)
privateBrowsingRecommend.dismiss()
}
}
layout.findViewById<Button>(R.id.cfr_neg_button).apply {
setOnClickListener {
privateBrowsingRecommend.dismiss()
}
}
// We want to show the popup only after privateBrowsingButton is available.
// Otherwise, we will encounter an activity token error.
binding.privateBrowsingButton.post {
runIfFragmentIsAttached {
context.settings().showedPrivateModeContextualFeatureRecommender = true
context.settings().lastCfrShownTimeInMillis = System.currentTimeMillis()
privateBrowsingRecommend.showAsDropDown(
binding.privateBrowsingButton, 0, CFR_Y_OFFSET, Gravity.TOP or Gravity.END
)
}
}
}
}
private fun hideOnboardingIfNeeded() {
if (!onboarding.userHasBeenOnboarded()) {
onboarding.finish()
requireContext().components.appStore.dispatch(
AppAction.ModeChange(
mode = currentMode.getCurrentMode()
)
)
}
}
private fun hideOnboardingAndOpenSearch() {
hideOnboardingIfNeeded()
appBarLayout?.setExpanded(true, true)
navigateToSearch()
}
@VisibleForTesting
internal fun navigateToSearch() {
val directions =
HomeFragmentDirections.actionGlobalSearchDialog(
sessionId = null
)
nav(R.id.homeFragment, directions, getToolbarNavOptions(requireContext()))
Events.searchBarTapped.record(Events.SearchBarTappedExtra("HOME"))
}
@SuppressWarnings("ComplexMethod", "LongMethod")
private fun createHomeMenu(context: Context, menuButtonView: WeakReference<MenuButton>) =
HomeMenu(
this.viewLifecycleOwner,
context,
onItemTapped = {
if (it !is HomeMenu.Item.DesktopMode) {
hideOnboardingIfNeeded()
}
when (it) {
HomeMenu.Item.Settings -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalSettingsFragment()
)
HomeMenuMetrics.settingsItemClicked.record(NoExtras())
}
HomeMenu.Item.CustomizeHome -> {
HomeScreen.customizeHomeClicked.record(NoExtras())
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalHomeSettingsFragment()
)
}
is HomeMenu.Item.SyncAccount -> {
val directions = when (it.accountState) {
AccountState.AUTHENTICATED ->
BrowserFragmentDirections.actionGlobalAccountSettingsFragment()
AccountState.NEEDS_REAUTHENTICATION ->
BrowserFragmentDirections.actionGlobalAccountProblemFragment()
AccountState.NO_ACCOUNT ->
BrowserFragmentDirections.actionGlobalTurnOnSync()
}
nav(
R.id.homeFragment,
directions
)
}
HomeMenu.Item.Bookmarks -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id)
)
}
HomeMenu.Item.History -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalHistoryFragment()
)
}
HomeMenu.Item.Downloads -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalDownloadsFragment()
)
}
HomeMenu.Item.Help -> {
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getSumoURLForTopic(context, HELP),
newTab = true,
from = BrowserDirection.FromHome
)
}
HomeMenu.Item.WhatsNew -> {
WhatsNew.userViewedWhatsNew(context)
Events.whatsNewTapped.record(NoExtras())
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getWhatsNewUrl(context),
newTab = true,
from = BrowserDirection.FromHome
)
}
// We need to show the snackbar while the browsing data is deleting(if "Delete
// browsing data on quit" is activated). After the deletion is over, the snackbar
// is dismissed.
HomeMenu.Item.Quit -> activity?.let { activity ->
deleteAndQuit(
activity,
viewLifecycleOwner.lifecycleScope,
view?.let { view ->
FenixSnackbar.make(
view = view,
isDisplayedWithBrowserToolbar = false
)
}
)
}
HomeMenu.Item.ReconnectSync -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalAccountProblemFragment()
)
}
HomeMenu.Item.Extensions -> {
nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalAddonsManagementFragment()
)
}
is HomeMenu.Item.DesktopMode -> {
context.settings().openNextTabInDesktopMode = it.checked
}
}
},
onHighlightPresent = { menuButtonView.get()?.setHighlight(it) },
onMenuBuilderChanged = { menuButtonView.get()?.menuBuilder = it }
)
private fun subscribeToTabCollections(): Observer<List<TabCollection>> {
return Observer<List<TabCollection>> {
requireComponents.core.tabCollectionStorage.cachedTabCollections = it
requireComponents.appStore.dispatch(AppAction.CollectionsChange(it))
}.also { observer ->
requireComponents.core.tabCollectionStorage.getCollections().observe(this, observer)
}
}
private fun registerCollectionStorageObserver() {
requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
}
private fun showRenamedSnackbar() {
view?.let { view ->
val string = view.context.getString(R.string.snackbar_collection_renamed)
FenixSnackbar.make(
view = view,
duration = Snackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = false
)
.setText(string)
.setAnchorView(snackbarAnchorView)
.show()
}
}
private fun openTabsTray() {
findNavController().nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalTabsTrayFragment()
)
}
// TODO use [FenixTabCounterToolbarButton] instead of [TabCounter]:
// https://github.com/mozilla-mobile/fenix/issues/16792
private fun updateTabCounter(browserState: BrowserState) {
val tabCount = if (browsingModeManager.mode.isPrivate) {
browserState.privateTabs.size
} else {
browserState.normalTabs.size
}
binding.tabButton.setCountWithAnimation(tabCount)
// The add_tabs_to_collections_button is added at runtime. We need to search for it in the same way.
sessionControlView?.view?.findViewById<MaterialButton>(R.id.add_tabs_to_collections_button)
?.isVisible = tabCount > 0
}
private fun displayWallpaperIfEnabled() {
if (shouldEnableWallpaper()) {
val wallpaperManger = requireComponents.wallpaperManager
// We only want to update the wallpaper when it's different from the default one
// as the default is applied already on xml by default.
if (wallpaperManger.currentWallpaper != WallpaperManager.defaultWallpaper) {
wallpaperManger.updateWallpaper(binding.wallpaperImageView, wallpaperManger.currentWallpaper)
}
}
}
// We want to show the animation in a time when the user less distracted
// The Heuristics are:
// 1) The animation hasn't shown before.
// 2) The user has onboarded.
// 3) It's the third time the user enters the app.
// 4) The user is part of the right audience.
@Suppress("MagicNumber")
private fun shouldAnimateLogoForWallpaper(): Boolean {
val localContext = context ?: return false
val settings = localContext.settings()
return shouldEnableWallpaper() && settings.shouldAnimateFirefoxLogo &&
onboarding.userHasBeenOnboarded() &&
settings.numberOfAppLaunches >= 3
}
private fun shouldEnableWallpaper() =
(activity as? HomeActivity)?.themeManager?.currentTheme?.isPrivate?.not() ?: false
companion object {
const val ALL_NORMAL_TABS = "all_normal"
const val ALL_PRIVATE_TABS = "all_private"
private const val FOCUS_ON_ADDRESS_BAR = "focusOnAddressBar"
private const val CFR_WIDTH_DIVIDER = 1.7
private const val CFR_Y_OFFSET = -20
// Sponsored top sites titles and search engine names used for filtering
const val AMAZON_SPONSORED_TITLE = "Amazon"
const val AMAZON_SEARCH_ENGINE_NAME = "Amazon.com"
const val EBAY_SPONSORED_TITLE = "eBay"
// Elevation for undo toasts
internal const val TOAST_ELEVATION = 80f
}
}