/* 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.animation.Animator import android.content.Context import android.content.DialogInterface 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.View.AccessibilityDelegate import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.widget.Button import android.widget.LinearLayout import android.widget.PopupWindow import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog 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 androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import com.google.android.material.appbar.AppBarLayout import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.view.bottomBarShadow import kotlinx.android.synthetic.main.fragment_home.view.bottom_bar import kotlinx.android.synthetic.main.fragment_home.view.homeAppBar import kotlinx.android.synthetic.main.fragment_home.view.menuButton import kotlinx.android.synthetic.main.fragment_home.view.sessionControlRecyclerView import kotlinx.android.synthetic.main.fragment_home.view.tab_button import kotlinx.android.synthetic.main.fragment_home.view.toolbar import kotlinx.android.synthetic.main.fragment_home.view.toolbarLayout import kotlinx.android.synthetic.main.fragment_home.view.toolbar_wrapper import kotlinx.android.synthetic.main.no_collections_message.view.add_tabs_to_collections_button import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay 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.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom 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.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.tips.FenixTipManager import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.metrics 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.home.mozonline.showPrivacyPopWindow 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.sessioncontrol.viewholders.CollectionViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.DefaultTopSitesView import org.mozilla.fenix.onboarding.FenixOnboarding 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.theme.ThemeManager import org.mozilla.fenix.utils.ToolbarPopupWindow import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.whatsnew.WhatsNew import java.lang.ref.WeakReference import kotlin.math.min @ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "LargeClass") class HomeFragment : Fragment() { private val args by navArgs() private lateinit var bundleArgs: Bundle private val homeViewModel: HomeScreenViewModel by activityViewModels() private val snackbarAnchorView: View? get() = when (requireContext().settings().toolbarPosition) { ToolbarPosition.BOTTOM -> toolbarLayout ToolbarPosition.TOP -> null } private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager private val collectionStorageObserver = object : TabCollectionStorage.Observer { override fun onCollectionRenamed(tabCollection: TabCollection, title: String) { lifecycleScope.launch(Main) { view?.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 lateinit var homeFragmentStore: HomeFragmentStore private var _sessionControlInteractor: SessionControlInteractor? = null protected val sessionControlInteractor: SessionControlInteractor get() = _sessionControlInteractor!! private var sessionControlView: SessionControlView? = null private var appBarLayout: AppBarLayout? = null private lateinit var currentMode: CurrentMode private val topSitesFeature = ViewBoundFeatureWrapper() @VisibleForTesting internal var getMenuButton: () -> MenuButton? = { menuButton } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bundleArgs = args.toBundle() lifecycleScope.launch(IO) { if (!onboarding.userHasBeenOnboarded()) { requireComponents.analytics.metrics.track(Event.OpenedAppFirstRun) } } if (!onboarding.userHasBeenOnboarded() && requireContext().settings().shouldShowPrivacyPopWindow && Config.channel.isMozillaOnline) { showPrivacyPopWindow(requireContext(), requireActivity()) } } @Suppress("LongMethod") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.fragment_home, container, false) val activity = activity as HomeActivity val components = requireComponents currentMode = CurrentMode( view.context, onboarding, browsingModeManager, ::dispatchModeChanges ) homeFragmentStore = StoreProvider.get(this) { HomeFragmentStore( HomeFragmentState( collections = components.core.tabCollectionStorage.cachedTabCollections, expandedCollections = emptySet(), mode = currentMode.getCurrentMode(), topSites = components.core.topSitesStorage.cachedTopSites, tip = components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { FenixTipManager( listOf( MasterPasswordTipProvider( requireContext(), ::navToSavedLogins, ::dismissTip ) ) ).getTip() }, showCollectionPlaceholder = false ) ) } topSitesFeature.set( feature = TopSitesFeature( view = DefaultTopSitesView(homeFragmentStore), storage = components.core.topSitesStorage, config = ::getTopSitesConfig ), owner = viewLifecycleOwner, view = view ) _sessionControlInteractor = SessionControlInteractor( DefaultSessionControlController( activity = activity, settings = components.settings, engine = components.core.engine, metrics = components.analytics.metrics, 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, fragmentStore = homeFragmentStore, navController = findNavController(), viewLifecycleScope = viewLifecycleOwner.lifecycleScope, hideOnboarding = ::hideOnboardingAndOpenSearch, registerCollectionStorageObserver = ::registerCollectionStorageObserver, showDeleteCollectionPrompt = ::showDeleteCollectionPrompt, showTabTray = ::openTabTray, handleSwipedItemDeletionCancel = ::handleSwipedItemDeletionCancel ) ) updateLayout(view) sessionControlView = SessionControlView( view.sessionControlRecyclerView, viewLifecycleOwner, sessionControlInteractor, homeViewModel ) updateSessionControlView(view) appBarLayout = view.homeAppBar activity.themeManager.applyStatusBarTheme(activity) return view } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) getMenuButton()?.dismissMenu() } private fun dismissTip(tip: Tip) { sessionControlInteractor.onCloseTip(tip) } /** * 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( settings.topSitesMaxLimit, if (settings.showTopFrecentSites) FrecencyThresholdOption.SKIP_ONE_TIME_PAGES else null ) } /** * 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(view: View) { if (browsingModeManager.mode == BrowsingMode.Private) { view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { sessionControlView?.update(it) } } else { sessionControlView?.update(homeFragmentStore.state) view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { sessionControlView?.update(it) } } } private fun updateLayout(view: View) { when (view.context.settings().toolbarPosition) { ToolbarPosition.TOP -> { view.toolbarLayout.layoutParams = CoordinatorLayout.LayoutParams( ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.WRAP_CONTENT ).apply { gravity = Gravity.TOP } ConstraintSet().apply { clone(view.toolbarLayout) clear(view.bottom_bar.id, BOTTOM) clear(view.bottomBarShadow.id, BOTTOM) connect(view.bottom_bar.id, TOP, PARENT_ID, TOP) connect(view.bottomBarShadow.id, TOP, view.bottom_bar.id, BOTTOM) connect(view.bottomBarShadow.id, BOTTOM, PARENT_ID, BOTTOM) applyTo(view.toolbarLayout) } view.bottom_bar.background = AppCompatResources.getDrawable( view.context, view.context.theme.resolveAttribute(R.attr.bottomBarBackgroundTop) ) view.homeAppBar.updateLayoutParams { topMargin = resources.getDimensionPixelSize(R.dimen.home_fragment_top_toolbar_header_margin) } } ToolbarPosition.BOTTOM -> { } } } @Suppress("LongMethod", "ComplexMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) observeSearchEngineChanges() createHomeMenu(requireContext(), WeakReference(view.menuButton)) createTabCounterMenu(view) view.menuButton.setColorFilter( ContextCompat.getColor( requireContext(), ThemeManager.resolveAttribute(R.attr.primaryText, requireContext()) ) ) view.toolbar.compoundDrawablePadding = view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding) view.toolbar_wrapper.setOnClickListener { navigateToSearch() requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) } view.toolbar_wrapper.setOnLongClickListener { ToolbarPopupWindow.show( WeakReference(it), handlePasteAndGo = sessionControlInteractor::onPasteAndGo, handlePaste = sessionControlInteractor::onPaste, copyVisible = false ) true } view.tab_button.setOnClickListener { openTabTray() } PrivateBrowsingButtonView( privateBrowsingButton, browsingModeManager ) { newMode -> if (newMode == BrowsingMode.Private) { requireContext().settings().incrementNumTimesPrivateModeOpened() } if (onboarding.userHasBeenOnboarded()) { homeFragmentStore.dispatch( HomeFragmentAction.ModeChange(Mode.fromBrowsingMode(newMode)) ) } } 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() } else if (bundleArgs.getLong(FOCUS_ON_COLLECTION, -1) >= 0) { // No need to scroll to async'd loaded TopSites if we want to scroll to collections. homeViewModel.shouldScrollToTopSites = false /* Triggered when the user has added a tab to a collection and has tapped * the View action on the [TabsTrayDialogFragment] snackbar.*/ scrollAndAnimateCollection(bundleArgs.getLong(FOCUS_ON_COLLECTION, -1)) } } 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) search_engine_icon?.setImageDrawable(searchIcon) } else { search_engine_icon.setImageDrawable(null) } } } } private fun createTabCounterMenu(view: View) { 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( view.context, onItemTapped, iconColor = if (mode == BrowsingMode.Private) { ContextCompat.getColor(requireContext(), R.color.primary_text_private_theme) } else { null } ) val inverseBrowsingMode = when (mode) { BrowsingMode.Normal -> BrowsingMode.Private BrowsingMode.Private -> BrowsingMode.Normal } tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode) view.tab_button.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 bundleArgs.clear() } override fun onStart() { super.onStart() subscribeToTabCollections() val context = requireContext() val components = context.components homeFragmentStore.dispatch( HomeFragmentAction.Change( collections = components.core.tabCollectionStorage.cachedTabCollections, mode = currentMode.getCurrentMode(), topSites = components.core.topSitesStorage.cachedTopSites, tip = components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { FenixTipManager( listOf( MasterPasswordTipProvider( requireContext(), ::navToSavedLogins, ::dismissTip ) ) ).getTip() }, showCollectionPlaceholder = components.settings.showCollectionsPlaceholderOnHome ) ) 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(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()) } } private fun navToSavedLogins() { findNavController().navigate(HomeFragmentDirections.actionGlobalSavedLoginsAuthFragment()) } private fun dispatchModeChanges(mode: Mode) { if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) { homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode)) } } private fun showDeleteCollectionPrompt( tabCollection: TabCollection, title: String?, message: String, wasSwiped: Boolean, handleSwipedItemDeletionCancel: () -> Unit ) { val context = context ?: return AlertDialog.Builder(context).apply { setTitle(title) setMessage(message) setNegativeButton(R.string.tab_collection_dialog_negative) { dialog: DialogInterface, _ -> if (wasSwiped) { handleSwipedItemDeletionCancel() } dialog.cancel() } setPositiveButton(R.string.tab_collection_dialog_positive) { dialog: DialogInterface, _ -> // Use fragment's lifecycle; the view may be gone by the time dialog is interacted with. lifecycleScope.launch(IO) { context.components.core.tabCollectionStorage.removeCollection(tabCollection) context.components.analytics.metrics.track(Event.CollectionRemoved) }.invokeOnCompletion { dialog.dismiss() } } create() }.show() } override fun onResume() { super.onResume() if (browsingModeManager.mode == BrowsingMode.Private) { activity?.window?.setBackgroundDrawableResource(R.drawable.private_home_background_gradient) } hideToolbar() } override fun onPause() { super.onPause() if (browsingModeManager.mode == BrowsingMode.Private) { activity?.window?.setBackgroundDrawable( ColorDrawable( ContextCompat.getColor( requireContext(), R.color.foundation_private_theme ) ) ) } } 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