/* 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.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.os.StrictMode import android.view.Display.FLAG_SECURE import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout import android.widget.PopupWindow 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.doOnLayout 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.ViewModelProvider 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.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.view.* import kotlinx.android.synthetic.main.no_collections_message.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.menu.view.MenuButton import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.store.BrowserStore 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.consumeFrom import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.content.res.resolveAttribute import org.mozilla.fenix.BrowserDirection 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.cfr.SearchWidgetCFR 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.TabCounterMenu 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.settings 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.FragmentPreDrawManager 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 { ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652 } 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 onCollectionCreated(title: String, sessions: List) { scrollAndAnimateCollection() } override fun onTabsAdded(tabCollection: TabCollection, sessions: List) { scrollAndAnimateCollection(tabCollection) } override fun onCollectionRenamed(tabCollection: TabCollection, title: String) { showRenamedSnackbar() } } private val sessionManager: SessionManager get() = requireComponents.core.sessionManager 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 lateinit var currentMode: CurrentMode private val topSitesFeature = ViewBoundFeatureWrapper() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) postponeEnterTransition() bundleArgs = args.toBundle() lifecycleScope.launch(IO) { if (!onboarding.userHasBeenOnboarded()) { requireComponents.analytics.metrics.track(Event.OpenedAppFirstRun) } } } @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 = components.settings.showCollectionsPlaceholderOnHome ) ) } topSitesFeature.set( feature = TopSitesFeature( view = DefaultTopSitesView(homeFragmentStore), storage = components.core.topSitesStorage, config = ::getTopSitesConfig ), owner = this, view = view ) _sessionControlInteractor = SessionControlInteractor( DefaultSessionControlController( activity = activity, settings = components.settings, engine = components.core.engine, metrics = components.analytics.metrics, sessionManager = sessionManager, tabCollectionStorage = components.core.tabCollectionStorage, addTabUseCase = components.useCases.tabsUseCases.addTab, 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) activity.themeManager.applyStatusBarTheme(activity) return view } 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. */ private fun getTopSitesConfig(): TopSitesConfig { val settings = requireContext().settings() return TopSitesConfig(settings.topSitesMaxLimit, settings.showTopFrecentSites) } /** * 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) FragmentPreDrawManager(this).execute { val homeViewModel: HomeScreenViewModel by activityViewModels { ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652 } homeViewModel.layoutManagerState?.also { parcelable -> sessionControlView!!.view.layoutManager?.onRestoreInstanceState(parcelable) } homeViewModel.layoutManagerState = null // We have to delay so that the keyboard collapses and the view is resized before the // animation from SearchFragment happens delay(ANIMATION_DELAY) } viewLifecycleOwner.lifecycleScope.launch(IO) { // This is necessary due to a bug in viewLifecycleOwner. See: // https://github.com/mozilla-mobile/android-components/blob/master/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt#L32-L56 // TODO remove when viewLifecycleOwner is fixed val context = context ?: return@launch val iconSize = context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size) val searchEngine = context.components.search.provider.getDefaultEngine(context) val searchIcon = BitmapDrawable(context.resources, searchEngine.icon) searchIcon.setBounds(0, 0, iconSize, iconSize) withContext(Main) { search_engine_icon?.setImageDrawable(searchIcon) } } createHomeMenu(requireContext(), WeakReference(view.menuButton)) val tabCounterMenu = TabCounterMenu( view.context, metrics = view.context.components.analytics.metrics ) { if (it is TabCounterMenu.Item.NewTab) { (activity as HomeActivity).browsingModeManager.mode = it.mode } } val inverseBrowsingMode = when ((activity as HomeActivity).browsingModeManager.mode) { BrowsingMode.Normal -> BrowsingMode.Private BrowsingMode.Private -> BrowsingMode.Normal } tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode) view.tab_button.setOnLongClickListener { tabCounterMenu.menuController.show(anchor = it) true } 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 { hideOnboardingIfNeeded() 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)) ) } } // We call this onLayout so that the bottom bar width is correctly set for us to center // the CFR in. view.toolbar_wrapper.doOnLayout { val willNavigateToSearch = !bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR) if (!browsingModeManager.mode.isPrivate && !willNavigateToSearch) { SearchWidgetCFR( context = view.context, settings = view.context.settings(), metrics = view.context.components.analytics.metrics ) { view.toolbar_wrapper }.displayIfNecessary() } } if (browsingModeManager.mode.isPrivate) { requireActivity().window.addFlags(FLAG_SECURE) } else { requireActivity().window.clearFlags(FLAG_SECURE) } 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() } } private fun removeAllTabsAndShowSnackbar(sessionCode: String) { if (sessionCode == ALL_PRIVATE_TABS) { sessionManager.removePrivateSessions() } else { sessionManager.removeNormalSessions() } 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 bundleArgs.clear() requireActivity().window.clearFlags(FLAG_SECURE) } 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 && context.settings().showPrivateModeCfr ) { 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, _ -> viewLifecycleOwner.lifecycleScope.launch(IO) { context.components.core.tabCollectionStorage.removeCollection(tabCollection) context.components.analytics.metrics.track(Event.CollectionRemoved) }.invokeOnCompletion { dialog.dismiss() } } create() }.show() } override fun onStop() { super.onStop() val homeViewModel: HomeScreenViewModel by activityViewModels { ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652 } homeViewModel.layoutManagerState = sessionControlView!!.view.layoutManager?.onSaveInstanceState() } 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