1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.fenix.home
7 import android.annotation.SuppressLint
8 import android.content.res.ColorStateList
9 import android.content.res.Configuration
10 import android.graphics.drawable.BitmapDrawable
11 import android.graphics.drawable.ColorDrawable
12 import android.net.Uri
13 import android.os.Bundle
14 import android.os.StrictMode
15 import android.view.Gravity
16 import android.view.LayoutInflater
17 import android.view.View
18 import android.view.ViewGroup
19 import android.widget.Button
20 import android.widget.LinearLayout
21 import android.widget.PopupWindow
22 import androidx.annotation.VisibleForTesting
23 import androidx.appcompat.content.res.AppCompatResources
24 import androidx.constraintlayout.widget.ConstraintLayout
25 import androidx.constraintlayout.widget.ConstraintSet
26 import androidx.constraintlayout.widget.ConstraintSet.BOTTOM
27 import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID
28 import androidx.constraintlayout.widget.ConstraintSet.TOP
29 import androidx.coordinatorlayout.widget.CoordinatorLayout
30 import androidx.core.content.ContextCompat
31 import androidx.core.graphics.drawable.toDrawable
32 import androidx.core.view.isGone
33 import androidx.core.view.isVisible
34 import androidx.core.view.updateLayoutParams
35 import androidx.fragment.app.Fragment
36 import androidx.fragment.app.activityViewModels
37 import androidx.lifecycle.Observer
38 import androidx.lifecycle.lifecycleScope
39 import androidx.navigation.fragment.findNavController
40 import androidx.navigation.fragment.navArgs
41 import androidx.recyclerview.widget.LinearLayoutManager
42 import androidx.recyclerview.widget.LinearSmoothScroller
43 import androidx.recyclerview.widget.RecyclerView.SmoothScroller
44 import com.google.android.material.appbar.AppBarLayout
45 import com.google.android.material.button.MaterialButton
46 import com.google.android.material.snackbar.Snackbar
47 import kotlinx.coroutines.Dispatchers.IO
48 import kotlinx.coroutines.Dispatchers.Main
49 import kotlinx.coroutines.MainScope
50 import kotlinx.coroutines.delay
51 import kotlinx.coroutines.flow.map
52 import kotlinx.coroutines.launch
53 import mozilla.components.browser.menu.view.MenuButton
54 import mozilla.components.browser.state.search.SearchEngine
55 import mozilla.components.browser.state.selector.findTab
56 import mozilla.components.browser.state.selector.normalTabs
57 import mozilla.components.browser.state.selector.privateTabs
58 import mozilla.components.browser.state.state.BrowserState
59 import mozilla.components.browser.state.state.searchEngines
60 import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
61 import mozilla.components.browser.state.store.BrowserStore
62 import mozilla.components.concept.menu.Orientation
63 import mozilla.components.concept.menu.candidate.DrawableMenuIcon
64 import mozilla.components.concept.menu.candidate.TextMenuCandidate
65 import mozilla.components.concept.storage.FrecencyThresholdOption
66 import mozilla.components.concept.sync.AccountObserver
67 import mozilla.components.concept.sync.AuthType
68 import mozilla.components.concept.sync.OAuthAccount
69 import mozilla.components.feature.tab.collections.TabCollection
70 import mozilla.components.feature.top.sites.TopSitesConfig
71 import mozilla.components.feature.top.sites.TopSitesFeature
72 import mozilla.components.feature.top.sites.TopSitesFrecencyConfig
73 import mozilla.components.feature.top.sites.TopSitesProviderConfig
74 import mozilla.components.lib.state.ext.consumeFlow
75 import mozilla.components.lib.state.ext.consumeFrom
76 import mozilla.components.service.glean.private.NoExtras
77 import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
78 import mozilla.components.support.ktx.android.content.getColorFromAttr
79 import mozilla.components.support.ktx.android.content.res.resolveAttribute
80 import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
81 import org.mozilla.fenix.Config
82 import org.mozilla.fenix.GleanMetrics.Events
83 import org.mozilla.fenix.GleanMetrics.HomeScreen
84 import org.mozilla.fenix.GleanMetrics.PrivateBrowsingShortcutCfr
85 import org.mozilla.fenix.GleanMetrics.UnifiedSearch
86 import org.mozilla.fenix.HomeActivity
87 import org.mozilla.fenix.R
88 import org.mozilla.fenix.addons.showSnackBar
89 import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions
90 import org.mozilla.fenix.browser.browsingmode.BrowsingMode
91 import org.mozilla.fenix.components.FenixSnackbar
92 import org.mozilla.fenix.components.PrivateShortcutCreateManager
93 import org.mozilla.fenix.components.TabCollectionStorage
94 import org.mozilla.fenix.components.appstate.AppAction
95 import org.mozilla.fenix.components.toolbar.ToolbarPosition
96 import org.mozilla.fenix.databinding.FragmentHomeBinding
97 import org.mozilla.fenix.ext.components
98 import org.mozilla.fenix.ext.containsQueryParameters
99 import org.mozilla.fenix.ext.hideToolbar
100 import org.mozilla.fenix.ext.increaseTapArea
101 import org.mozilla.fenix.ext.nav
102 import org.mozilla.fenix.ext.requireComponents
103 import org.mozilla.fenix.ext.runIfFragmentIsAttached
104 import org.mozilla.fenix.ext.scaleToBottomOfView
105 import org.mozilla.fenix.ext.settings
106 import org.mozilla.fenix.gleanplumb.DefaultMessageController
107 import org.mozilla.fenix.gleanplumb.MessagingFeature
108 import org.mozilla.fenix.gleanplumb.NimbusMessagingController
109 import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow
110 import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
111 import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
112 import org.mozilla.fenix.home.recentbookmarks.RecentBookmarksFeature
113 import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksController
114 import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabFeature
115 import org.mozilla.fenix.home.recentsyncedtabs.controller.DefaultRecentSyncedTabController
116 import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature
117 import org.mozilla.fenix.home.recenttabs.controller.DefaultRecentTabsController
118 import org.mozilla.fenix.home.recentvisits.RecentVisitsFeature
119 import org.mozilla.fenix.home.recentvisits.controller.DefaultRecentVisitsController
120 import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
121 import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
122 import org.mozilla.fenix.home.sessioncontrol.SessionControlView
123 import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder
124 import org.mozilla.fenix.home.topsites.DefaultTopSitesView
125 import org.mozilla.fenix.nimbus.FxNimbus
126 import org.mozilla.fenix.onboarding.FenixOnboarding
127 import org.mozilla.fenix.perf.MarkersFragmentLifecycleCallbacks
128 import org.mozilla.fenix.perf.runBlockingIncrement
129 import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
130 import org.mozilla.fenix.tabstray.TabsTrayAccessPoint
131 import org.mozilla.fenix.utils.Settings.Companion.TOP_SITES_PROVIDER_MAX_THRESHOLD
132 import org.mozilla.fenix.utils.ToolbarPopupWindow
133 import org.mozilla.fenix.utils.allowUndo
134 import org.mozilla.fenix.wallpapers.Wallpaper
135 import java.lang.ref.WeakReference
136 import kotlin.math.min
138 @Suppress("TooManyFunctions", "LargeClass")
139 class HomeFragment : Fragment() {
140 private val args by navArgs<HomeFragmentArgs>()
143 internal lateinit var bundleArgs: Bundle
146 @Suppress("VariableNaming")
147 internal var _binding: FragmentHomeBinding? = null
148 private val binding get() = _binding!!
150 private val homeViewModel: HomeScreenViewModel by activityViewModels()
152 private val snackbarAnchorView: View?
153 get() = when (requireContext().settings().toolbarPosition) {
154 ToolbarPosition.BOTTOM -> binding.toolbarLayout
155 ToolbarPosition.TOP -> null
158 private val searchSelectorMenu by lazy {
160 context = requireContext(),
161 interactor = sessionControlInteractor,
165 private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager
167 private val collectionStorageObserver = object : TabCollectionStorage.Observer {
168 @SuppressLint("NotifyDataSetChanged")
169 override fun onCollectionRenamed(tabCollection: TabCollection, title: String) {
170 lifecycleScope.launch(Main) {
171 binding.sessionControlRecyclerView.adapter?.notifyDataSetChanged()
173 showRenamedSnackbar()
177 private val store: BrowserStore
178 get() = requireComponents.core.store
180 private val onboarding by lazy {
181 requireComponents.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
182 FenixOnboarding(requireContext())
186 private var _sessionControlInteractor: SessionControlInteractor? = null
187 private val sessionControlInteractor: SessionControlInteractor
188 get() = _sessionControlInteractor!!
190 private var sessionControlView: SessionControlView? = null
191 private var appBarLayout: AppBarLayout? = null
192 private lateinit var currentMode: CurrentMode
194 private var lastAppliedWallpaperName: String = Wallpaper.defaultName
196 private val topSitesFeature = ViewBoundFeatureWrapper<TopSitesFeature>()
197 private val messagingFeature = ViewBoundFeatureWrapper<MessagingFeature>()
198 private val recentTabsListFeature = ViewBoundFeatureWrapper<RecentTabsListFeature>()
199 private val recentSyncedTabFeature = ViewBoundFeatureWrapper<RecentSyncedTabFeature>()
200 private val recentBookmarksFeature = ViewBoundFeatureWrapper<RecentBookmarksFeature>()
201 private val historyMetadataFeature = ViewBoundFeatureWrapper<RecentVisitsFeature>()
204 internal var getMenuButton: () -> MenuButton? = { binding.menuButton }
206 override fun onCreate(savedInstanceState: Bundle?) {
207 // DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
208 val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
210 super.onCreate(savedInstanceState)
212 bundleArgs = args.toBundle()
214 if (!onboarding.userHasBeenOnboarded() &&
215 requireContext().settings().shouldShowPrivacyPopWindow &&
216 Config.channel.isMozillaOnline
218 showPrivacyPopWindow(requireContext(), requireActivity())
221 // DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
222 requireComponents.core.engine.profiler?.addMarker(
223 MarkersFragmentLifecycleCallbacks.MARKER_NAME,
225 "HomeFragment.onCreate",
229 @Suppress("LongMethod")
230 override fun onCreateView(
231 inflater: LayoutInflater,
232 container: ViewGroup?,
233 savedInstanceState: Bundle?,
235 // DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
236 val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
238 _binding = FragmentHomeBinding.inflate(inflater, container, false)
239 val activity = activity as HomeActivity
240 val components = requireComponents
242 val currentWallpaperName = requireContext().settings().currentWallpaperName
243 applyWallpaper(wallpaperName = currentWallpaperName, orientationChange = false)
245 currentMode = CurrentMode(
249 ::dispatchModeChanges,
252 components.appStore.dispatch(AppAction.ModeChange(currentMode.getCurrentMode()))
254 lifecycleScope.launch(IO) {
255 if (requireContext().settings().showPocketRecommendationsFeature) {
256 val categories = components.core.pocketStoriesService.getStories()
257 .groupBy { story -> story.category }
258 .map { (category, stories) -> PocketRecommendedStoriesCategory(category, stories) }
260 components.appStore.dispatch(AppAction.PocketStoriesCategoriesChange(categories))
262 if (requireContext().settings().showPocketSponsoredStories) {
263 components.appStore.dispatch(
264 AppAction.PocketSponsoredStoriesChange(
265 components.core.pocketStoriesService.getSponsoredStories(),
270 components.appStore.dispatch(AppAction.PocketStoriesClean)
274 if (requireContext().settings().isExperimentationEnabled) {
275 messagingFeature.set(
276 feature = MessagingFeature(
277 appStore = requireComponents.appStore,
279 owner = viewLifecycleOwner,
284 if (requireContext().settings().showTopSitesFeature) {
286 feature = TopSitesFeature(
287 view = DefaultTopSitesView(
288 appStore = components.appStore,
289 settings = components.settings,
291 storage = components.core.topSitesStorage,
292 config = ::getTopSitesConfig,
294 owner = viewLifecycleOwner,
299 if (requireContext().settings().showRecentTabsFeature) {
300 recentTabsListFeature.set(
301 feature = RecentTabsListFeature(
302 browserStore = components.core.store,
303 appStore = components.appStore,
305 owner = viewLifecycleOwner,
309 if (requireContext().settings().enableTaskContinuityEnhancements) {
310 recentSyncedTabFeature.set(
311 feature = RecentSyncedTabFeature(
312 context = requireContext(),
313 appStore = requireComponents.appStore,
314 syncStore = requireComponents.backgroundServices.syncStore,
315 storage = requireComponents.backgroundServices.syncedTabsStorage,
316 accountManager = requireComponents.backgroundServices.accountManager,
317 historyStorage = requireComponents.core.historyStorage,
318 coroutineScope = viewLifecycleOwner.lifecycleScope,
320 owner = viewLifecycleOwner,
326 if (requireContext().settings().showRecentBookmarksFeature) {
327 recentBookmarksFeature.set(
328 feature = RecentBookmarksFeature(
329 appStore = components.appStore,
330 bookmarksUseCase = run {
331 requireContext().components.useCases.bookmarksUseCases
333 scope = viewLifecycleOwner.lifecycleScope,
335 owner = viewLifecycleOwner,
340 if (requireContext().settings().historyMetadataUIFeature) {
341 historyMetadataFeature.set(
342 feature = RecentVisitsFeature(
343 appStore = components.appStore,
344 historyMetadataStorage = components.core.historyStorage,
345 historyHighlightsStorage = components.core.lazyHistoryStorage,
346 scope = viewLifecycleOwner.lifecycleScope,
348 owner = viewLifecycleOwner,
353 requireContext().settings().showUnifiedSearchFeature.let {
354 binding.searchSelectorButton.isVisible = it
355 binding.searchEngineIcon.isGone = it
358 binding.searchSelectorButton.apply {
360 val orientation = if (context.settings().shouldUseBottomToolbar) {
366 UnifiedSearch.searchMenuTapped.record(NoExtras())
367 searchSelectorMenu.menuController.show(
368 anchor = it.findViewById(R.id.search_selector),
369 orientation = orientation,
374 _sessionControlInteractor = SessionControlInteractor(
375 controller = DefaultSessionControlController(
377 settings = components.settings,
378 engine = components.core.engine,
379 messageController = DefaultMessageController(
380 appStore = components.appStore,
381 messagingController = NimbusMessagingController(components.analytics.messagingStorage),
382 homeActivity = activity,
385 tabCollectionStorage = components.core.tabCollectionStorage,
386 addTabUseCase = components.useCases.tabsUseCases.addTab,
387 restoreUseCase = components.useCases.tabsUseCases.restore,
388 reloadUrlUseCase = components.useCases.sessionUseCases.reload,
389 selectTabUseCase = components.useCases.tabsUseCases.selectTab,
390 appStore = components.appStore,
391 navController = findNavController(),
392 viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
393 hideOnboarding = ::hideOnboardingAndOpenSearch,
394 registerCollectionStorageObserver = ::registerCollectionStorageObserver,
395 removeCollectionWithUndo = ::removeCollectionWithUndo,
396 showTabTray = ::openTabsTray,
398 recentTabController = DefaultRecentTabsController(
399 selectTabUseCase = components.useCases.tabsUseCases.selectTab,
400 navController = findNavController(),
401 appStore = components.appStore,
403 recentSyncedTabController = DefaultRecentSyncedTabController(
404 tabsUseCase = requireComponents.useCases.tabsUseCases,
405 navController = findNavController(),
406 accessPoint = TabsTrayAccessPoint.HomeRecentSyncedTab,
407 appStore = components.appStore,
409 recentBookmarksController = DefaultRecentBookmarksController(
411 navController = findNavController(),
412 appStore = components.appStore,
414 recentVisitsController = DefaultRecentVisitsController(
415 navController = findNavController(),
416 appStore = components.appStore,
417 selectOrAddTabUseCase = components.useCases.tabsUseCases.selectOrAddTab,
418 storage = components.core.historyStorage,
419 scope = viewLifecycleOwner.lifecycleScope,
420 store = components.core.store,
422 pocketStoriesController = DefaultPocketStoriesController(
423 homeActivity = activity,
424 appStore = components.appStore,
428 updateLayout(binding.root)
429 sessionControlView = SessionControlView(
430 containerView = binding.sessionControlRecyclerView,
431 viewLifecycleOwner = viewLifecycleOwner,
432 interactor = sessionControlInteractor,
435 updateSessionControlView()
437 appBarLayout = binding.homeAppBar
439 disableAppBarDragging()
441 activity.themeManager.applyStatusBarTheme(activity)
443 FxNimbus.features.homescreen.recordExposure()
445 // DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
446 requireComponents.core.engine.profiler?.addMarker(
447 MarkersFragmentLifecycleCallbacks.MARKER_NAME,
449 "HomeFragment.onCreateView",
454 override fun onConfigurationChanged(newConfig: Configuration) {
455 super.onConfigurationChanged(newConfig)
457 getMenuButton()?.dismissMenu()
459 val currentWallpaperName = requireContext().settings().currentWallpaperName
460 applyWallpaper(wallpaperName = currentWallpaperName, orientationChange = true)
464 * Returns a [TopSitesConfig] which specifies how many top sites to display and whether or
465 * not frequently visited sites should be displayed.
468 internal fun getTopSitesConfig(): TopSitesConfig {
469 val settings = requireContext().settings()
470 return TopSitesConfig(
471 totalSites = settings.topSitesMaxLimit,
472 frecencyConfig = TopSitesFrecencyConfig(
473 FrecencyThresholdOption.SKIP_ONE_TIME_PAGES,
474 ) { !Uri.parse(it.url).containsQueryParameters(settings.frecencyFilterQuery) },
475 providerConfig = TopSitesProviderConfig(
476 showProviderTopSites = settings.showContileFeature,
477 maxThreshold = TOP_SITES_PROVIDER_MAX_THRESHOLD,
478 providerFilter = { topSite ->
479 when (store.state.search.selectedOrDefaultSearchEngine?.name) {
480 AMAZON_SEARCH_ENGINE_NAME -> topSite.title != AMAZON_SPONSORED_TITLE
481 EBAY_SPONSORED_TITLE -> topSite.title != EBAY_SPONSORED_TITLE
490 * The [SessionControlView] is forced to update with our current state when we call
491 * [HomeFragment.onCreateView] in order to be able to draw everything at once with the current
492 * data in our store. The [View.consumeFrom] coroutine dispatch
493 * doesn't get run right away which means that we won't draw on the first layout pass.
495 private fun updateSessionControlView() {
496 if (browsingModeManager.mode == BrowsingMode.Private) {
497 binding.root.consumeFrom(requireContext().components.appStore, viewLifecycleOwner) {
498 sessionControlView?.update(it)
501 sessionControlView?.update(requireContext().components.appStore.state)
503 binding.root.consumeFrom(requireContext().components.appStore, viewLifecycleOwner) {
504 sessionControlView?.update(it, shouldReportMetrics = true)
509 private fun disableAppBarDragging() {
510 if (binding.homeAppBar.layoutParams != null) {
511 val appBarLayoutParams = binding.homeAppBar.layoutParams as CoordinatorLayout.LayoutParams
512 val appBarBehavior = AppBarLayout.Behavior()
513 appBarBehavior.setDragCallback(
514 object : AppBarLayout.Behavior.DragCallback() {
515 override fun canDrag(appBarLayout: AppBarLayout): Boolean {
520 appBarLayoutParams.behavior = appBarBehavior
522 binding.homeAppBar.setExpanded(true)
525 private fun updateLayout(view: View) {
526 when (requireContext().settings().toolbarPosition) {
527 ToolbarPosition.TOP -> {
528 binding.toolbarLayout.layoutParams = CoordinatorLayout.LayoutParams(
529 ConstraintLayout.LayoutParams.MATCH_PARENT,
530 ConstraintLayout.LayoutParams.WRAP_CONTENT,
532 gravity = Gravity.TOP
535 ConstraintSet().apply {
536 clone(binding.toolbarLayout)
537 clear(binding.bottomBar.id, BOTTOM)
538 clear(binding.bottomBarShadow.id, BOTTOM)
539 connect(binding.bottomBar.id, TOP, PARENT_ID, TOP)
540 connect(binding.bottomBarShadow.id, TOP, binding.bottomBar.id, BOTTOM)
541 connect(binding.bottomBarShadow.id, BOTTOM, PARENT_ID, BOTTOM)
542 applyTo(binding.toolbarLayout)
545 binding.bottomBar.background = AppCompatResources.getDrawable(
547 view.context.theme.resolveAttribute(R.attr.bottomBarBackgroundTop),
550 binding.homeAppBar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
552 resources.getDimensionPixelSize(R.dimen.home_fragment_top_toolbar_header_margin)
555 ToolbarPosition.BOTTOM -> {
560 @Suppress("LongMethod", "ComplexMethod")
561 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
562 // DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL!
563 val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime()
565 super.onViewCreated(view, savedInstanceState)
566 HomeScreen.homeScreenDisplayed.record(NoExtras())
567 HomeScreen.homeScreenViewCount.add()
569 observeSearchEngineChanges()
570 observeSearchEngineNameChanges()
571 observeWallpaperUpdates()
575 context = view.context,
576 lifecycleOwner = viewLifecycleOwner,
577 homeActivity = activity as HomeActivity,
578 navController = findNavController(),
579 menuButton = WeakReference(binding.menuButton),
580 hideOnboardingIfNeeded = ::hideOnboardingIfNeeded,
584 context = requireContext(),
585 browsingModeManager = browsingModeManager,
586 navController = findNavController(),
587 tabCounter = binding.tabButton,
590 binding.toolbar.compoundDrawablePadding =
591 view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding)
592 binding.toolbarWrapper.setOnClickListener {
596 binding.toolbarWrapper.setOnLongClickListener {
597 ToolbarPopupWindow.show(
599 handlePasteAndGo = sessionControlInteractor::onPasteAndGo,
600 handlePaste = sessionControlInteractor::onPaste,
606 PrivateBrowsingButtonView(binding.privateBrowsingButton, browsingModeManager) { newMode ->
607 sessionControlInteractor.onPrivateModeButtonClicked(
609 onboarding.userHasBeenOnboarded(),
613 consumeFrom(requireComponents.core.store) {
617 homeViewModel.sessionToDelete?.also {
618 if (it == ALL_NORMAL_TABS || it == ALL_PRIVATE_TABS) {
619 removeAllTabsAndShowSnackbar(it)
621 removeTabAndShowSnackbar(it)
625 homeViewModel.sessionToDelete = null
627 updateTabCounter(requireComponents.core.store.state)
629 if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) {
631 } else if (bundleArgs.getBoolean(SCROLL_TO_COLLECTION)) {
633 delay(ANIM_SCROLL_DELAY)
634 val smoothScroller: SmoothScroller =
635 object : LinearSmoothScroller(sessionControlView!!.view.context) {
636 override fun getVerticalSnapPreference(): Int {
640 val recyclerView = sessionControlView!!.view
641 val adapter = recyclerView.adapter!!
642 val collectionPosition = IntRange(0, adapter.itemCount - 1).firstOrNull {
643 adapter.getItemViewType(it) == CollectionHeaderViewHolder.LAYOUT_ID
645 collectionPosition?.run {
646 val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager
647 smoothScroller.targetPosition = this
648 linearLayoutManager.startSmoothScroll(smoothScroller)
653 consumeFlow(requireComponents.core.store) { flow ->
654 flow.map { state -> state.search }
657 updateSearchSelectorMenu(search.searchEngines)
661 // DO NOT MOVE ANYTHING BELOW THIS addMarker CALL!
662 requireComponents.core.engine.profiler?.addMarker(
663 MarkersFragmentLifecycleCallbacks.MARKER_NAME,
665 "HomeFragment.onViewCreated",
669 private fun updateSearchSelectorMenu(searchEngines: List<SearchEngine>) {
670 val searchEngineList = searchEngines
674 start = DrawableMenuIcon(
675 drawable = it.icon.toDrawable(resources),
676 tint = if (it.type == SearchEngine.Type.APPLICATION) {
677 requireContext().getColorFromAttr(R.attr.textPrimary)
683 sessionControlInteractor.onMenuItemTapped(SearchSelectorMenu.Item.SearchEngine(it))
687 searchSelectorMenu.menuController.submitList(searchSelectorMenu.menuItems(searchEngineList))
690 private fun observeSearchEngineChanges() {
691 consumeFlow(store) { flow ->
692 flow.map { state -> state.search.selectedOrDefaultSearchEngine }
694 .collect { searchEngine ->
695 val name = searchEngine?.name
696 val icon = searchEngine?.let {
697 // Changing dimensions doesn't not affect the icon size, not sure what the
698 // code is doing: https://github.com/mozilla-mobile/fenix/issues/27763
700 requireContext().resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
701 BitmapDrawable(requireContext().resources, searchEngine.icon).apply {
702 setBounds(0, 0, iconSize, iconSize)
706 if (requireContext().settings().showUnifiedSearchFeature) {
707 binding.searchSelectorButton.setIcon(icon, name)
709 binding.searchEngineIcon.setImageDrawable(icon)
716 * Method used to listen to search engine name changes and trigger a top sites update accordingly
718 private fun observeSearchEngineNameChanges() {
719 consumeFlow(store) { flow ->
721 when (state.search.selectedOrDefaultSearchEngine?.name) {
722 AMAZON_SEARCH_ENGINE_NAME -> AMAZON_SPONSORED_TITLE
723 EBAY_SPONSORED_TITLE -> EBAY_SPONSORED_TITLE
729 topSitesFeature.withFeature {
730 it.storage.notifyObservers { onStorageUpdated() }
736 private fun removeAllTabsAndShowSnackbar(sessionCode: String) {
737 if (sessionCode == ALL_PRIVATE_TABS) {
738 requireComponents.useCases.tabsUseCases.removePrivateTabs()
740 requireComponents.useCases.tabsUseCases.removeNormalTabs()
743 val snackbarMessage = if (sessionCode == ALL_PRIVATE_TABS) {
744 getString(R.string.snackbar_private_tabs_closed)
746 getString(R.string.snackbar_tabs_closed)
749 viewLifecycleOwner.lifecycleScope.allowUndo(
752 requireContext().getString(R.string.snackbar_deleted_undo),
754 requireComponents.useCases.tabsUseCases.undo.invoke()
757 anchorView = snackbarAnchorView,
761 private fun removeTabAndShowSnackbar(sessionId: String) {
762 val tab = store.state.findTab(sessionId) ?: return
764 requireComponents.useCases.tabsUseCases.removeTab(sessionId)
766 val snackbarMessage = if (tab.content.private) {
767 requireContext().getString(R.string.snackbar_private_tab_closed)
769 requireContext().getString(R.string.snackbar_tab_closed)
772 viewLifecycleOwner.lifecycleScope.allowUndo(
775 requireContext().getString(R.string.snackbar_deleted_undo),
777 requireComponents.useCases.tabsUseCases.undo.invoke()
778 findNavController().navigate(
779 HomeFragmentDirections.actionGlobalBrowser(null),
783 anchorView = snackbarAnchorView,
787 override fun onDestroyView() {
788 super.onDestroyView()
790 _sessionControlInteractor = null
791 sessionControlView = null
795 lastAppliedWallpaperName = Wallpaper.defaultName
798 override fun onStart() {
801 subscribeToTabCollections()
803 val context = requireContext()
805 requireComponents.backgroundServices.accountManagerAvailableQueue.runIfReadyOrQueue {
806 // By the time this code runs, we may not be attached to a context or have a view lifecycle owner.
807 if ((this@HomeFragment).view?.context == null) {
808 return@runIfReadyOrQueue
811 requireComponents.backgroundServices.accountManager.register(
813 owner = this@HomeFragment.viewLifecycleOwner,
815 requireComponents.backgroundServices.accountManager.register(
816 object : AccountObserver {
817 override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
818 if (authType != AuthType.Existing) {
822 duration = Snackbar.LENGTH_SHORT,
823 isDisplayedWithBrowserToolbar = false,
825 .setText(it.context.getString(R.string.onboarding_firefox_account_sync_is_on))
826 .setAnchorView(binding.toolbarLayout)
832 owner = this@HomeFragment.viewLifecycleOwner,
836 if (browsingModeManager.mode.isPrivate &&
837 // We will be showing the search dialog and don't want to show the CFR while the dialog shows
838 !bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR) &&
839 context.settings().shouldShowPrivateModeCfr
841 recommendPrivateBrowsingShortcut()
844 // We only want this observer live just before we navigate away to the collection creation screen
845 requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver)
847 lifecycleScope.launch(IO) {
848 requireComponents.reviewPromptController.promptReview(requireActivity())
852 private fun dispatchModeChanges(mode: Mode) {
853 if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) {
854 requireContext().components.appStore.dispatch(AppAction.ModeChange(mode))
859 internal fun removeCollectionWithUndo(tabCollection: TabCollection) {
860 val snackbarMessage = getString(R.string.snackbar_collection_deleted)
862 lifecycleScope.allowUndo(
865 getString(R.string.snackbar_deleted_undo),
867 requireComponents.core.tabCollectionStorage.createCollection(tabCollection)
870 elevation = TOAST_ELEVATION,
874 lifecycleScope.launch(IO) {
875 requireComponents.core.tabCollectionStorage.removeCollection(tabCollection)
879 override fun onResume() {
881 if (browsingModeManager.mode == BrowsingMode.Private) {
882 activity?.window?.setBackgroundDrawableResource(R.drawable.private_home_background_gradient)
887 // Whenever a tab is selected its last access timestamp is automatically updated by A-C.
888 // However, in the case of resuming the app to the home fragment, we already have an
889 // existing selected tab, but its last access timestamp is outdated. No action is
890 // triggered to cause an automatic update on warm start (no tab selection occurs). So we
891 // update it manually here.
892 requireComponents.useCases.sessionUseCases.updateLastAccess()
895 override fun onPause() {
897 if (browsingModeManager.mode == BrowsingMode.Private) {
898 activity?.window?.setBackgroundDrawable(
900 ContextCompat.getColor(
902 R.color.fx_mobile_private_layer_color_1,
908 // Counterpart to the update in onResume to keep the last access timestamp of the selected
910 requireComponents.useCases.sessionUseCases.updateLastAccess()
913 @SuppressLint("InflateParams")
914 private fun recommendPrivateBrowsingShortcut() {
915 context?.let { context ->
916 val layout = LayoutInflater.from(context)
917 .inflate(R.layout.pbm_shortcut_popup, null)
918 val privateBrowsingRecommend =
922 (resources.displayMetrics.widthPixels / CFR_WIDTH_DIVIDER).toInt(),
923 (resources.displayMetrics.heightPixels / CFR_WIDTH_DIVIDER).toInt(),
925 LinearLayout.LayoutParams.WRAP_CONTENT,
928 layout.findViewById<Button>(R.id.cfr_pos_button).apply {
929 this.increaseTapArea(CFR_TAP_INCREASE_DPS)
932 PrivateBrowsingShortcutCfr.addShortcut.record(NoExtras())
933 PrivateShortcutCreateManager.createPrivateShortcut(context)
934 privateBrowsingRecommend.dismiss()
937 layout.findViewById<Button>(R.id.cfr_neg_button).apply {
939 PrivateBrowsingShortcutCfr.cancel.record()
940 privateBrowsingRecommend.dismiss()
943 // We want to show the popup only after privateBrowsingButton is available.
944 // Otherwise, we will encounter an activity token error.
945 binding.privateBrowsingButton.post {
946 runIfFragmentIsAttached {
947 context.settings().showedPrivateModeContextualFeatureRecommender = true
948 context.settings().lastCfrShownTimeInMillis = System.currentTimeMillis()
949 privateBrowsingRecommend.showAsDropDown(
950 binding.privateBrowsingButton,
953 Gravity.TOP or Gravity.END,
960 private fun hideOnboardingIfNeeded() {
961 if (!onboarding.userHasBeenOnboarded()) {
963 requireContext().components.appStore.dispatch(
964 AppAction.ModeChange(
965 mode = currentMode.getCurrentMode(),
971 private fun hideOnboardingAndOpenSearch() {
972 hideOnboardingIfNeeded()
973 appBarLayout?.setExpanded(true, true)
978 internal fun navigateToSearch() {
980 HomeFragmentDirections.actionGlobalSearchDialog(
984 nav(R.id.homeFragment, directions, getToolbarNavOptions(requireContext()))
986 Events.searchBarTapped.record(Events.SearchBarTappedExtra("HOME"))
989 private fun subscribeToTabCollections(): Observer<List<TabCollection>> {
990 return Observer<List<TabCollection>> {
991 requireComponents.core.tabCollectionStorage.cachedTabCollections = it
992 requireComponents.appStore.dispatch(AppAction.CollectionsChange(it))
994 requireComponents.core.tabCollectionStorage.getCollections().observe(this, observer)
998 private fun registerCollectionStorageObserver() {
999 requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
1002 private fun showRenamedSnackbar() {
1004 val string = view.context.getString(R.string.snackbar_collection_renamed)
1007 duration = Snackbar.LENGTH_LONG,
1008 isDisplayedWithBrowserToolbar = false,
1011 .setAnchorView(snackbarAnchorView)
1016 private fun openTabsTray() {
1017 findNavController().nav(
1019 HomeFragmentDirections.actionGlobalTabsTrayFragment(),
1023 // TODO use [FenixTabCounterToolbarButton] instead of [TabCounter]:
1024 // https://github.com/mozilla-mobile/fenix/issues/16792
1025 private fun updateTabCounter(browserState: BrowserState) {
1026 val tabCount = if (browsingModeManager.mode.isPrivate) {
1027 browserState.privateTabs.size
1029 browserState.normalTabs.size
1032 binding.tabButton.setCountWithAnimation(tabCount)
1033 // The add_tabs_to_collections_button is added at runtime. We need to search for it in the same way.
1034 sessionControlView?.view?.findViewById<MaterialButton>(R.id.add_tabs_to_collections_button)
1035 ?.isVisible = tabCount > 0
1039 internal fun shouldEnableWallpaper() =
1040 (activity as? HomeActivity)?.themeManager?.currentTheme?.isPrivate?.not() ?: false
1042 private fun applyWallpaper(wallpaperName: String, orientationChange: Boolean) {
1044 !shouldEnableWallpaper() ||
1045 (wallpaperName == lastAppliedWallpaperName && !orientationChange) -> return
1046 Wallpaper.nameIsDefault(wallpaperName) -> {
1047 binding.wallpaperImageView.isVisible = false
1048 lastAppliedWallpaperName = wallpaperName
1051 runBlockingIncrement {
1052 // loadBitmap does file lookups based on name, so we don't need a fully
1053 // qualified type to load the image
1054 val wallpaper = Wallpaper.Default.copy(name = wallpaperName)
1055 val wallpaperImage =
1056 requireComponents.useCases.wallpaperUseCases.loadBitmap(wallpaper)
1057 wallpaperImage?.let {
1058 it.scaleToBottomOfView(binding.wallpaperImageView)
1059 binding.wallpaperImageView.isVisible = true
1060 lastAppliedWallpaperName = wallpaperName
1062 with(binding.wallpaperImageView) {
1066 text = resources.getString(R.string.wallpaper_select_error_snackbar_message),
1069 // If setting a wallpaper failed reset also the contrasting text color.
1070 requireContext().settings().currentWallpaperTextColor = 0L
1071 lastAppliedWallpaperName = Wallpaper.defaultName
1076 // Logo color should be updated in all cases.
1077 applyWallpaperTextColor()
1081 * Apply a color better contrasting with the current wallpaper to the Fenix logo and private mode switcher.
1084 internal fun applyWallpaperTextColor() {
1085 val tintColor = when (val color = requireContext().settings().currentWallpaperTextColor.toInt()) {
1086 0 -> null // a null ColorStateList will clear the current tint
1087 else -> ColorStateList.valueOf(color)
1090 binding.wordmarkText.imageTintList = tintColor
1091 binding.privateBrowsingButton.imageTintList = tintColor
1094 private fun observeWallpaperUpdates() {
1095 consumeFrom(requireComponents.appStore) {
1096 val currentWallpaper = it.wallpaperState.currentWallpaper
1097 if (currentWallpaper.name != lastAppliedWallpaperName) {
1098 applyWallpaper(wallpaperName = currentWallpaper.name, orientationChange = false)
1104 const val ALL_NORMAL_TABS = "all_normal"
1105 const val ALL_PRIVATE_TABS = "all_private"
1107 private const val FOCUS_ON_ADDRESS_BAR = "focusOnAddressBar"
1109 private const val SCROLL_TO_COLLECTION = "scrollToCollection"
1110 private const val ANIM_SCROLL_DELAY = 100L
1112 private const val CFR_WIDTH_DIVIDER = 1.7
1113 private const val CFR_Y_OFFSET = -20
1115 private const val CFR_TAP_INCREASE_DPS = 6
1117 // Sponsored top sites titles and search engine names used for filtering
1118 const val AMAZON_SPONSORED_TITLE = "Amazon"
1119 const val AMAZON_SEARCH_ENGINE_NAME = "Amazon.com"
1120 const val EBAY_SPONSORED_TITLE = "eBay"
1122 // Elevation for undo toasts
1123 internal const val TOAST_ELEVATION = 80f