/* 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.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.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.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.Observer import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController 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.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect 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.state.MediaState.State.PLAYING 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.media.ext.pauseIfPlaying import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.ktx.android.util.dpToPx import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.addons.runIfFragmentIsAttached 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.sessionsOfType import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.toTab import org.mozilla.fenix.home.sessioncontrol.AdapterItem import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.SessionControlAdapter 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 @SuppressWarnings("TooManyFunctions", "LargeClass") class HomeFragment : Fragment() { private val homeViewModel: HomeScreenViewModel by viewModels { ViewModelProvider.AndroidViewModelFactory(requireActivity().application) } private val sharedViewModel: SharedViewModel by activityViewModels() 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 singleSessionObserver = object : Session.Observer { override fun onTitleChanged(session: Session, title: String) { if (deleteAllSessionsJob == null) emitSessionChanges() } } 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 var deleteAllSessionsJob: (suspend () -> Unit)? = null private var pendingSessionDeletion: PendingSessionDeletion? = null data class PendingSessionDeletion(val deletionJob: (suspend () -> Unit), val sessionId: String) 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() 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 val sessionObserver = BrowserSessionsObserver( sessionManager, requireComponents.core.store, singleSessionObserver ) { emitSessionChanges() } viewLifecycleOwner.lifecycle.addObserver(sessionObserver) currentMode = CurrentMode( view.context, onboarding, browsingModeManager, ::dispatchModeChanges ) homeFragmentStore = StoreProvider.get(this) { HomeFragmentStore( HomeFragmentState( collections = requireComponents.core.tabCollectionStorage.cachedTabCollections, expandedCollections = emptySet(), mode = currentMode.getCurrentMode(), tabs = emptyList(), topSites = requireComponents.core.topSiteStorage.cachedTopSites, tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip() ) ) } _sessionControlInteractor = SessionControlInteractor( DefaultSessionControlController( store = requireComponents.core.store, activity = activity, fragmentStore = homeFragmentStore, navController = findNavController(), browsingModeManager = browsingModeManager, viewLifecycleScope = viewLifecycleOwner.lifecycleScope, closeTab = ::closeTab, closeAllTabs = ::closeAllTabs, getListOfTabs = ::getListOfTabs, hideOnboarding = ::hideOnboardingAndOpenSearch, invokePendingDeleteJobs = ::invokePendingDeleteJobs, registerCollectionStorageObserver = ::registerCollectionStorageObserver, scrollToTheTop = ::scrollToTheTop, showDeleteCollectionPrompt = ::showDeleteCollectionPrompt, openSettingsScreen = ::openSettingsScreen, openSearchScreen = ::navigateToSearch, openWhatsNewLink = { openInNormalTab(SupportUtils.getWhatsNewUrl(activity)) }, openPrivacyNotice = { openInNormalTab(SupportUtils.getMozillaPageUrl(PRIVATE_NOTICE)) }, showTabTray = ::openTabTray ) ) updateLayout(view) setOffset(view) sessionControlView = SessionControlView( view.sessionControlRecyclerView, sessionControlInteractor, homeViewModel ) activity.themeManager.applyStatusBarTheme(activity) view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { sessionControlView?.update(it) if (context?.settings()?.useNewTabTray == true) { view.tab_button.setCountWithAnimation(it.tabs.size) } } return view } 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.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 { invokePendingDeleteJobs() hideOnboardingIfNeeded() navigateToSearch() requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) } view.add_tab_button.setOnClickListener { invokePendingDeleteJobs() hideOnboardingIfNeeded() navigateToSearch() } view.tab_button.setOnClickListener { openTabTray() } PrivateBrowsingButtonView( privateBrowsingButton, browsingModeManager ) { newMode -> invokePendingDeleteJobs() 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() } } } override fun onDestroyView() { super.onDestroyView() _sessionControlInteractor = null sessionControlView = null requireView().homeAppBar.removeOnOffsetChangedListener(homeAppBarOffSetListener) } 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(), tabs = getListOfSessions().toTabs(), 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 closeTab(sessionId: String) { val deletionJob = pendingSessionDeletion?.deletionJob context?.let { if (sessionManager.findSessionById(sessionId)?.toTab(it)?.mediaState == PLAYING) { it.components.core.store.state.media.pauseIfPlaying() } } if (deletionJob == null) { removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate) } else { viewLifecycleOwner.lifecycleScope.launch { deletionJob.invoke() }.invokeOnCompletion { pendingSessionDeletion = null removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate) } } } private fun closeAllTabs(isPrivateMode: Boolean) { val deletionJob = pendingSessionDeletion?.deletionJob context?.let { sessionManager.sessionsOfType(private = isPrivateMode).forEach { session -> if (session.toTab(it).mediaState == PLAYING) { it.components.core.store.state.media.pauseIfPlaying() } } } if (deletionJob == null) { removeAllTabsWithUndo( sessionManager.sessionsOfType(private = isPrivateMode), isPrivateMode ) } else { viewLifecycleOwner.lifecycleScope.launch { deletionJob.invoke() }.invokeOnCompletion { pendingSessionDeletion = null removeAllTabsWithUndo( sessionManager.sessionsOfType(private = isPrivateMode), isPrivateMode ) } } } private fun dispatchModeChanges(mode: Mode) { if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) { homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode)) } } private fun invokePendingDeleteJobs() { pendingSessionDeletion?.deletionJob?.let { viewLifecycleOwner.lifecycleScope.launch { it.invoke() }.invokeOnCompletion { pendingSessionDeletion = null } } deleteAllSessionsJob?.let { viewLifecycleOwner.lifecycleScope.launch { it.invoke() }.invokeOnCompletion { deleteAllSessionsJob = null } } } 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() { invokePendingDeleteJobs() 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() if (sharedViewModel.shouldScrollToSelectedTab) { scrollToSelectedTab() sharedViewModel.shouldScrollToSelectedTab = false } requireContext().settings().useNewTabTray.also { view?.add_tab_button?.isVisible = !it view?.tab_button?.isVisible = it } } 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