/* 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.DialogInterface import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable 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.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels 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.FragmentNavigator import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.transition.TransitionInflater 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.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main 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.session.Session import mozilla.components.browser.session.SessionManager import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.feature.media.ext.getSession import mozilla.components.feature.media.ext.pauseIfPlaying import mozilla.components.feature.media.ext.playIfPaused import mozilla.components.feature.media.state.MediaState import mozilla.components.feature.media.state.MediaStateMachine import mozilla.components.feature.tab.collections.TabCollection import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.BOTTOM import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.END import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.START import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.TOP import org.jetbrains.anko.constraint.layout.applyConstraintSet import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.FenixViewModelProvider import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.collections.SaveCollectionStep import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components 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.CollectionAction import org.mozilla.fenix.home.sessioncontrol.OnboardingAction import org.mozilla.fenix.home.sessioncontrol.SessionControlAction import org.mozilla.fenix.home.sessioncontrol.SessionControlChange import org.mozilla.fenix.home.sessioncontrol.SessionControlComponent import org.mozilla.fenix.home.sessioncontrol.SessionControlState import org.mozilla.fenix.home.sessioncontrol.SessionControlViewModel import org.mozilla.fenix.home.sessioncontrol.Tab import org.mozilla.fenix.home.sessioncontrol.TabAction import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder import org.mozilla.fenix.lib.Do import org.mozilla.fenix.mvi.ActionBusFactory import org.mozilla.fenix.mvi.getAutoDisposeObservable import org.mozilla.fenix.mvi.getManagedEmitter import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit import org.mozilla.fenix.share.ShareTab import org.mozilla.fenix.utils.FragmentPreDrawManager import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.whatsnew.WhatsNew @SuppressWarnings("TooManyFunctions", "LargeClass") class HomeFragment : Fragment() { private val bus = ActionBusFactory.get(this) private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager private val singleSessionObserver = object : Session.Observer { override fun onTitleChanged(session: Session, title: String) { if (deleteAllSessionsJob == null) emitSessionChanges() } override fun onIconChanged(session: Session, icon: Bitmap?) { 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 var homeMenu: HomeMenu? = null 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 val onboarding by lazy { FenixOnboarding(requireContext()) } private lateinit var sessionControlComponent: SessionControlComponent private lateinit var currentMode: CurrentMode override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) postponeEnterTransition() sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move) .setDuration(SHARED_TRANSITION_MS) val sessionObserver = BrowserSessionsObserver(sessionManager, singleSessionObserver) { emitSessionChanges() } lifecycle.addObserver(sessionObserver) 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) currentMode = CurrentMode( view.context, onboarding, browsingModeManager, getManagedEmitter() ) sessionControlComponent = SessionControlComponent( view.homeLayout, bus, FenixViewModelProvider.create( this, SessionControlViewModel::class.java ) { SessionControlViewModel( SessionControlState( emptyList(), emptySet(), requireComponents.core.tabCollectionStorage.cachedTabCollections, currentMode.getCurrentMode() ) ) } ) view.homeLayout.applyConstraintSet { sessionControlComponent.view { connect( TOP to BOTTOM of view.wordmark_spacer, START to START of PARENT_ID, END to END of PARENT_ID, BOTTOM to TOP of view.bottom_bar ) } } ActionBusFactory.get(this).logMergedObservables() val activity = activity as HomeActivity activity.themeManager.applyStatusBarTheme(activity) return view } @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 -> sessionControlComponent.view.layoutManager?.onRestoreInstanceState(parcelable) } homeLayout?.progress = homeViewModel.motionLayoutProgress homeViewModel.layoutManagerState = null } setupHomeMenu() viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { val iconSize = resources.getDimension(R.dimen.preference_icon_drawable_size).toInt() val searchEngine = requireComponents.search.provider.getDefaultEngine(requireContext()) val searchIcon = BitmapDrawable(resources, searchEngine.icon) searchIcon.setBounds(0, 0, iconSize, iconSize) withContext(Dispatchers.Main) { search_engine_icon?.setImageDrawable(searchIcon) } } with(view.menuButton) { var menu: PopupWindow? = null setOnClickListener { if (menu == null) { menu = homeMenu?.menuBuilder?.build(requireContext())?.show( anchor = it, orientation = BrowserMenu.Orientation.DOWN, onDismiss = { menu = null } ) } else { menu?.dismiss() } } } view.toolbar.compoundDrawablePadding = view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding) view.toolbar_wrapper.setOnClickListener { invokePendingDeleteJobs() hideOnboardingIfNeeded() val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment( sessionId = null ) val extras = FragmentNavigator.Extras.Builder() .addSharedElement(toolbar_wrapper, "toolbar_wrapper_transition") .build() nav(R.id.homeFragment, directions, extras) requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) } view.add_tab_button.setOnClickListener { invokePendingDeleteJobs() hideOnboardingIfNeeded() val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment( sessionId = null ) nav(R.id.homeFragment, directions) } PrivateBrowsingButtonView( privateBrowsingButton, browsingModeManager ) { newMode -> invokePendingDeleteJobs() if (newMode == BrowsingMode.Private) { requireContext().settings().incrementNumTimesPrivateModeOpened() } if (onboarding.userHasBeenOnboarded()) { getManagedEmitter().onNext( SessionControlChange.ModeChange(Mode.fromBrowsingMode(newMode)) ) } } // We need the shadow to be above the components. bottomBarShadow.bringToFront() } override fun onDestroyView() { homeMenu = null super.onDestroyView() } override fun onResume() { super.onResume() getAutoDisposeObservable() .subscribe { when (it) { is SessionControlAction.Tab -> handleTabAction(it.action) is SessionControlAction.Collection -> handleCollectionAction(it.action) is SessionControlAction.Onboarding -> handleOnboardingAction(it.action) } } val context = requireContext() val components = context.components getManagedEmitter().onNext( SessionControlChange.Change( tabs = getListOfSessions().toTabs(), mode = currentMode.getCurrentMode(), collections = components.core.tabCollectionStorage.cachedTabCollections ) ) (activity as AppCompatActivity).supportActionBar?.hide() requireComponents.backgroundServices.accountManager.register(currentMode, owner = this) requireComponents.backgroundServices.accountManager.register(object : AccountObserver { override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { if (authType != AuthType.Existing) { view?.let { FenixSnackbar.make(it, Snackbar.LENGTH_SHORT) .setText(it.context.getString(R.string.onboarding_firefox_account_sync_is_on)) .setAnchorView(bottom_bar) .show() } } } }, owner = this) if (context.settings().showPrivateModeContextualFeatureRecommender && browsingModeManager.mode.isPrivate && !PrivateShortcutCreateManager.doesPrivateBrowsingPinnedShortcutExist(context)) { recommendPrivateBrowsingShortcut() } } override fun onStart() { super.onStart() subscribeToTabCollections() // We only want this observer live just before we navigate away to the collection creation screen requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver) } private fun handleOnboardingAction(action: OnboardingAction) { Do exhaustive when (action) { is OnboardingAction.Finish -> { homeLayout?.progress = 0F hideOnboarding() } } } @SuppressWarnings("ComplexMethod", "LongMethod") private fun handleTabAction(action: TabAction) { Do exhaustive when (action) { is TabAction.SaveTabGroup -> { if (browsingModeManager.mode.isPrivate) return invokePendingDeleteJobs() saveTabToCollection(action.selectedTabSessionId) } is TabAction.Select -> { invokePendingDeleteJobs() val session = sessionManager.findSessionById(action.sessionId) sessionManager.select(session!!) val directions = HomeFragmentDirections.actionHomeFragmentToBrowserFragment(null) val extras = FragmentNavigator.Extras.Builder() .addSharedElement( action.tabView, "$TAB_ITEM_TRANSITION_NAME${action.sessionId}" ) .build() nav(R.id.homeFragment, directions, extras) } is TabAction.Close -> { if (pendingSessionDeletion?.deletionJob == null) { removeTabWithUndo(action.sessionId, browsingModeManager.mode.isPrivate) } else { pendingSessionDeletion?.deletionJob?.let { viewLifecycleOwner.lifecycleScope.launch { it.invoke() }.invokeOnCompletion { pendingSessionDeletion = null removeTabWithUndo(action.sessionId, browsingModeManager.mode.isPrivate) } } } } is TabAction.Share -> { invokePendingDeleteJobs() sessionManager.findSessionById(action.sessionId)?.let { session -> share(session.url) } } is TabAction.PauseMedia -> { MediaStateMachine.state.pauseIfPlaying() } is TabAction.PlayMedia -> { MediaStateMachine.state.playIfPaused() } is TabAction.CloseAll -> { if (pendingSessionDeletion?.deletionJob == null) { removeAllTabsWithUndo( sessionManager.sessionsOfType(private = action.private), action.private ) } else { pendingSessionDeletion?.deletionJob?.let { viewLifecycleOwner.lifecycleScope.launch { it.invoke() }.invokeOnCompletion { pendingSessionDeletion = null removeAllTabsWithUndo( sessionManager.sessionsOfType(private = action.private), action.private ) } } } } is TabAction.PrivateBrowsingLearnMore -> { (activity as HomeActivity).openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic (SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS), newTab = true, from = BrowserDirection.FromHome ) } is TabAction.ShareTabs -> { invokePendingDeleteJobs() val shareTabs = sessionManager .sessionsOfType(private = browsingModeManager.mode.isPrivate) .map { ShareTab(it.url, it.title) } .toList() share(tabs = shareTabs) } } } 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 createDeleteCollectionPrompt(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() } @SuppressWarnings("LongMethod") private fun handleCollectionAction(action: CollectionAction) { when (action) { is CollectionAction.Expand -> { getManagedEmitter() .onNext(SessionControlChange.ExpansionChange(action.collection, true)) } is CollectionAction.Collapse -> { getManagedEmitter() .onNext(SessionControlChange.ExpansionChange(action.collection, false)) } is CollectionAction.Delete -> { createDeleteCollectionPrompt(action.collection) } is CollectionAction.AddTab -> { requireComponents.analytics.metrics.track(Event.CollectionAddTabPressed) showCollectionCreationFragment( step = SaveCollectionStep.SelectTabs, selectedTabCollectionId = action.collection.id ) } is CollectionAction.Rename -> { showCollectionCreationFragment( step = SaveCollectionStep.RenameCollection, selectedTabCollectionId = action.collection.id ) requireComponents.analytics.metrics.track(Event.CollectionRenamePressed) } is CollectionAction.OpenTab -> { invokePendingDeleteJobs() val context = requireContext() val components = context.components val session = action.tab.restore( context = context, engine = components.core.engine, tab = action.tab, restoreSessionId = false ) if (session == null) { // We were unable to create a snapshot, so just load the tab instead (activity as HomeActivity).openToBrowserAndLoad( searchTermOrURL = action.tab.url, newTab = true, from = BrowserDirection.FromHome ) } else { components.core.sessionManager.add( session, true ) (activity as HomeActivity).openToBrowser(BrowserDirection.FromHome) } components.analytics.metrics.track(Event.CollectionTabRestored) } is CollectionAction.OpenTabs -> { invokePendingDeleteJobs() val context = requireContext() val components = context.components action.collection.tabs.reversed().forEach { val session = it.restore( context = context, engine = components.core.engine, tab = it, restoreSessionId = false ) if (session == null) { // We were unable to create a snapshot, so just load the tab instead components.useCases.tabsUseCases.addTab.invoke(it.url) } else { components.core.sessionManager.add( session, context.components.core.sessionManager.selectedSession == null ) } } viewLifecycleOwner.lifecycleScope.launch(Main) { delay(ANIM_SCROLL_DELAY) sessionControlComponent.view.smoothScrollToPosition(0) } components.analytics.metrics.track(Event.CollectionAllTabsRestored) } is CollectionAction.ShareTabs -> { val shareTabs = action.collection.tabs.map { ShareTab(it.url, it.title) } share(tabs = shareTabs) requireComponents.analytics.metrics.track(Event.CollectionShared) } is CollectionAction.RemoveTab -> { viewLifecycleOwner.lifecycleScope.launch(IO) { requireComponents.core.tabCollectionStorage.removeTabFromCollection( action.collection, action.tab ) } requireComponents.analytics.metrics.track(Event.CollectionTabRemoved) } } } override fun onPause() { invokePendingDeleteJobs() super.onPause() val homeViewModel: HomeScreenViewModel by activityViewModels { ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652 } homeViewModel.layoutManagerState = sessionControlComponent.view.layoutManager?.onSaveInstanceState() homeViewModel.motionLayoutProgress = homeLayout?.progress ?: 0F } private fun recommendPrivateBrowsingShortcut() { context?.let { val layout = LayoutInflater.from(it) .inflate(R.layout.pbm_shortcut_popup, null) val trackingOnboarding = PopupWindow( layout, (resources.displayMetrics.widthPixels / CFR_WIDTH_DIVIDER).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT, true ) layout.findViewById