/* 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.view.accessibility.AccessibilityEvent import android.widget.Button import android.widget.LinearLayout import android.widget.PopupWindow import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog 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.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels 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.appbar.AppBarLayout 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.BrowserMenu import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.menu.item.BrowserMenuImageText 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.normalTabs import mozilla.components.browser.state.selector.privateTabs 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.TopSite import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.support.ktx.android.util.dpToPx 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.providers.MigrationTipProvider 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.resetPoliciesAfter import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.toTab 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.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils.MozillaPage.PRIVATE_NOTICE import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.FragmentPreDrawManager import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.whatsnew.WhatsNew import java.lang.ref.WeakReference import kotlin.math.abs import kotlin.math.min @ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "LargeClass") class HomeFragment : Fragment() { private val args by navArgs() private val homeViewModel: HomeScreenViewModel by viewModels { ViewModelProvider.AndroidViewModelFactory(requireActivity().application) } private val snackbarAnchorView: View? get() { return if (requireContext().settings().shouldUseBottomToolbar) { toolbarLayout } else { null } } private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager private var homeAppBarOffset = 0 private val collectionStorageObserver = object : TabCollectionStorage.Observer { override fun onCollectionCreated(title: String, sessions: List) { scrollAndAnimateCollection(sessions.size) } override fun onTabsAdded(tabCollection: TabCollection, sessions: List) { scrollAndAnimateCollection(sessions.size, tabCollection) } override fun onCollectionRenamed(tabCollection: TabCollection, title: String) { showRenamedSnackbar() } } private val sessionManager: SessionManager get() = requireComponents.core.sessionManager private lateinit var homeAppBarOffSetListener: AppBarLayout.OnOffsetChangedListener private val onboarding by lazy { 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 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) postponeEnterTransition() lifecycleScope.launch(IO) { if (!onboarding.userHasBeenOnboarded()) { requireComponents.analytics.metrics.track(Event.OpenedAppFirstRun) } } } 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 currentMode = CurrentMode( view.context, onboarding, browsingModeManager, ::dispatchModeChanges ) homeFragmentStore = StoreProvider.get(this) { HomeFragmentStore( HomeFragmentState( collections = requireComponents.core.tabCollectionStorage.cachedTabCollections, expandedCollections = emptySet(), mode = currentMode.getCurrentMode(), topSites = StrictMode.allowThreadDiskReads().resetPoliciesAfter { requireComponents.core.topSiteStorage.cachedTopSites }, tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip() ) ) } _sessionControlInteractor = SessionControlInteractor( DefaultSessionControlController( activity = activity, fragmentStore = homeFragmentStore, navController = findNavController(), viewLifecycleScope = viewLifecycleOwner.lifecycleScope, getListOfTabs = ::getListOfTabs, hideOnboarding = ::hideOnboardingAndOpenSearch, registerCollectionStorageObserver = ::registerCollectionStorageObserver, showDeleteCollectionPrompt = ::showDeleteCollectionPrompt, openSettingsScreen = ::openSettingsScreen, openWhatsNewLink = { openInNormalTab(SupportUtils.getWhatsNewUrl(activity)) }, openPrivacyNotice = { openInNormalTab(SupportUtils.getMozillaPageUrl(PRIVATE_NOTICE)) }, showTabTray = ::openTabTray ) ) updateLayout(view) setOffset(view) sessionControlView = SessionControlView( view.sessionControlRecyclerView, sessionControlInteractor, homeViewModel ) updateSessionControlView(view) activity.themeManager.applyStatusBarTheme(activity) view.consumeFrom(requireComponents.core.store, viewLifecycleOwner) { val tabCount = if (currentMode.getCurrentMode() == Mode.Normal) { it.normalTabs.size } else { it.privateTabs.size } view.tab_button.setCountWithAnimation(tabCount) view.add_tabs_to_collections_button?.visibility = if (tabCount > 0) { View.VISIBLE } else { View.GONE } } return view } /** * 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. */ fun updateSessionControlView(view: View) { sessionControlView?.update(homeFragmentStore.state) view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { sessionControlView?.update(it) } } private fun updateLayout(view: View) { val shouldUseBottomToolbar = view.context.settings().shouldUseBottomToolbar if (!shouldUseBottomToolbar) { 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 = resources.getDrawable( ThemeManager.resolveAttribute(R.attr.bottomBarBackgroundTop, requireContext()), null ) view.homeAppBar.updateLayoutParams { topMargin = HEADER_MARGIN.dpToPx(resources.displayMetrics) } createNewAppBarListener(HEADER_MARGIN.dpToPx(resources.displayMetrics).toFloat()) view.homeAppBar.addOnOffsetChangedListener( homeAppBarOffSetListener ) } else { createNewAppBarListener(0F) view.homeAppBar.addOnOffsetChangedListener( homeAppBarOffSetListener ) } } @SuppressWarnings("LongMethod") 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)) view.tab_button.setOnLongClickListener { createTabCounterMenu(requireContext()).show(view.tab_button) 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.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 { if (!browsingModeManager.mode.isPrivate) { SearchWidgetCFR(view.context) { view.toolbar_wrapper }.displayIfNecessary() } } if (view.context.settings().accessibilityServicesEnabled && args.focusOnAddressBar) { // We cannot put this in the fragment_home.xml file as it breaks tests view.toolbar_wrapper.isFocusableInTouchMode = true viewLifecycleOwner.lifecycleScope.launch { view.toolbar_wrapper?.requestFocus() view.toolbar_wrapper?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) } } if (browsingModeManager.mode.isPrivate) { requireActivity().window.addFlags(FLAG_SECURE) } else { requireActivity().window.clearFlags(FLAG_SECURE) } } override fun onDestroyView() { super.onDestroyView() _sessionControlInteractor = null sessionControlView = null requireView().homeAppBar.removeOnOffsetChangedListener(homeAppBarOffSetListener) requireActivity().window.clearFlags(FLAG_SECURE) } override fun onStart() { super.onStart() subscribeToTabCollections() subscribeToTopSites() val context = requireContext() val components = context.components homeFragmentStore.dispatch( HomeFragmentAction.Change( collections = components.core.tabCollectionStorage.cachedTabCollections, mode = currentMode.getCurrentMode(), topSites = components.core.topSiteStorage.cachedTopSites, tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip() ) ) 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 (context.settings().showPrivateModeContextualFeatureRecommender && browsingModeManager.mode.isPrivate ) { recommendPrivateBrowsingShortcut() } // We only want this observer live just before we navigate away to the collection creation screen requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver) } private fun dispatchModeChanges(mode: Mode) { if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) { homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode)) } } private fun showDeleteCollectionPrompt(tabCollection: TabCollection) { val context = context ?: return AlertDialog.Builder(context).apply { val message = context.getString(R.string.tab_collection_dialog_message, tabCollection.title) setMessage(message) setNegativeButton(R.string.tab_collection_dialog_negative) { dialog: DialogInterface, _ -> 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() args.sessionToDelete?.also { sessionManager.findSessionById(it)?.let { session -> val snapshot = sessionManager.createSessionSnapshot(session) val state = snapshot.engineSession?.saveState() val isSelected = session.id == requireComponents.core.store.state.selectedTabId ?: false val snackbarMessage = if (snapshot.session.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), { sessionManager.add( snapshot.session, isSelected, engineSessionState = state ) findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToBrowserFragment(null)) }, operation = { }, anchorView = snackbarAnchorView ) requireComponents.useCases.tabsUseCases.removeTab.invoke(session) } } } override fun onPause() { super.onPause() if (browsingModeManager.mode == BrowsingMode.Private) { activity?.window?.setBackgroundDrawable( ColorDrawable( ContextCompat.getColor( requireContext(), R.color.foundation_private_theme ) ) ) } calculateNewOffset() } private fun recommendPrivateBrowsingShortcut() { context?.let { val layout = LayoutInflater.from(it) .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