/* 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.sessioncontrol import android.annotation.SuppressLint import android.view.LayoutInflater import android.widget.EditText import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.state.availableSearchEngines import mozilla.components.browser.state.state.searchEngines import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.ext.invoke import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.top.sites.TopSite import mozilla.components.support.ktx.android.view.showKeyboard import mozilla.components.support.ktx.kotlin.isUrl import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.GleanMetrics.Pings import org.mozilla.fenix.GleanMetrics.TopSites import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.collections.SaveCollectionStep import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.components.appstate.AppState import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.settings import org.mozilla.fenix.gleanplumb.Message import org.mozilla.fenix.gleanplumb.MessageController import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.Mode import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS import org.mozilla.fenix.utils.Settings import mozilla.components.feature.tab.collections.Tab as ComponentTab /** * [HomeFragment] controller. An interface that handles the view manipulation of the Tabs triggered * by the Interactor. */ @Suppress("TooManyFunctions") interface SessionControlController { /** * @see [CollectionInteractor.onCollectionAddTabTapped] */ fun handleCollectionAddTabTapped(collection: TabCollection) /** * @see [CollectionInteractor.onCollectionOpenTabClicked] */ fun handleCollectionOpenTabClicked(tab: ComponentTab) /** * @see [CollectionInteractor.onCollectionOpenTabsTapped] */ fun handleCollectionOpenTabsTapped(collection: TabCollection) /** * @see [CollectionInteractor.onCollectionRemoveTab] */ fun handleCollectionRemoveTab(collection: TabCollection, tab: ComponentTab, wasSwiped: Boolean) /** * @see [CollectionInteractor.onCollectionShareTabsClicked] */ fun handleCollectionShareTabsClicked(collection: TabCollection) /** * @see [CollectionInteractor.onDeleteCollectionTapped] */ fun handleDeleteCollectionTapped(collection: TabCollection) /** * @see [TopSiteInteractor.onOpenInPrivateTabClicked] */ fun handleOpenInPrivateTabClicked(topSite: TopSite) /** * @see [TabSessionInteractor.onPrivateBrowsingLearnMoreClicked] */ fun handlePrivateBrowsingLearnMoreClicked() /** * @see [TopSiteInteractor.onRenameTopSiteClicked] */ fun handleRenameTopSiteClicked(topSite: TopSite) /** * @see [TopSiteInteractor.onRemoveTopSiteClicked] */ fun handleRemoveTopSiteClicked(topSite: TopSite) /** * @see [CollectionInteractor.onRenameCollectionTapped] */ fun handleRenameCollectionTapped(collection: TabCollection) /** * @see [TopSiteInteractor.onSelectTopSite] */ fun handleSelectTopSite(topSite: TopSite, position: Int) /** * @see [TopSiteInteractor.onSettingsClicked] */ fun handleTopSiteSettingsClicked() /** * @see [TopSiteInteractor.onSponsorPrivacyClicked] */ fun handleSponsorPrivacyClicked() /** * @see [OnboardingInteractor.onStartBrowsingClicked] */ fun handleStartBrowsingClicked() /** * @see [OnboardingInteractor.onReadPrivacyNoticeClicked] */ fun handleReadPrivacyNoticeClicked() /** * @see [CollectionInteractor.onToggleCollectionExpanded] */ fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean) /** * @see [ToolbarInteractor.onPasteAndGo] */ fun handlePasteAndGo(clipboardText: String) /** * @see [ToolbarInteractor.onPaste] */ fun handlePaste(clipboardText: String) /** * @see [CollectionInteractor.onAddTabsToCollectionTapped] */ fun handleCreateCollection() /** * @see [CollectionInteractor.onRemoveCollectionsPlaceholder] */ fun handleRemoveCollectionsPlaceholder() /** * @see [CollectionInteractor.onCollectionMenuOpened] and [TopSiteInteractor.onTopSiteMenuOpened] */ fun handleMenuOpened() /** * @see [MessageCardInteractor.onMessageClicked] */ fun handleMessageClicked(message: Message) /** * @see [MessageCardInteractor.onMessageClosedClicked] */ fun handleMessageClosed(message: Message) /** * @see [MessageCardInteractor.onMessageDisplayed] */ fun handleMessageDisplayed(message: Message) /** * @see [TabSessionInteractor.onPrivateModeButtonClicked] */ fun handlePrivateModeButtonClicked(newMode: BrowsingMode, userHasBeenOnboarded: Boolean) /** * @see [CustomizeHomeIteractor.openCustomizeHomePage] */ fun handleCustomizeHomeTapped() /** * @see [OnboardingInteractor.showOnboardingDialog] */ fun handleShowOnboardingDialog() /** * @see [SessionControlInteractor.reportSessionMetrics] */ fun handleReportSessionMetrics(state: AppState) } @Suppress("TooManyFunctions", "LargeClass") class DefaultSessionControlController( private val activity: HomeActivity, private val settings: Settings, private val engine: Engine, private val metrics: MetricController, private val messageController: MessageController, private val store: BrowserStore, private val tabCollectionStorage: TabCollectionStorage, private val addTabUseCase: TabsUseCases.AddNewTabUseCase, private val restoreUseCase: TabsUseCases.RestoreUseCase, private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase, private val selectTabUseCase: TabsUseCases.SelectTabUseCase, private val appStore: AppStore, private val navController: NavController, private val viewLifecycleScope: CoroutineScope, private val hideOnboarding: () -> Unit, private val registerCollectionStorageObserver: () -> Unit, private val removeCollectionWithUndo: (tabCollection: TabCollection) -> Unit, private val showTabTray: () -> Unit ) : SessionControlController { override fun handleCollectionAddTabTapped(collection: TabCollection) { metrics.track(Event.CollectionAddTabPressed) showCollectionCreationFragment( step = SaveCollectionStep.SelectTabs, selectedTabCollectionId = collection.id ) } override fun handleMenuOpened() { dismissSearchDialogIfDisplayed() } override fun handleCollectionOpenTabClicked(tab: ComponentTab) { dismissSearchDialogIfDisplayed() restoreUseCase.invoke( activity, engine, tab, onTabRestored = { activity.openToBrowser(BrowserDirection.FromHome) selectTabUseCase.invoke(it) reloadUrlUseCase.invoke(it) }, onFailure = { activity.openToBrowserAndLoad( searchTermOrURL = tab.url, newTab = true, from = BrowserDirection.FromHome ) } ) metrics.track(Event.CollectionTabRestored) } override fun handleCollectionOpenTabsTapped(collection: TabCollection) { restoreUseCase.invoke( activity, engine, collection, onFailure = { url -> addTabUseCase.invoke(url) } ) showTabTray() metrics.track(Event.CollectionAllTabsRestored) } override fun handleCollectionRemoveTab( collection: TabCollection, tab: ComponentTab, wasSwiped: Boolean ) { metrics.track(Event.CollectionTabRemoved) if (collection.tabs.size == 1) { removeCollectionWithUndo(collection) } else { viewLifecycleScope.launch { tabCollectionStorage.removeTabFromCollection(collection, tab) } } } override fun handleCollectionShareTabsClicked(collection: TabCollection) { dismissSearchDialogIfDisplayed() showShareFragment( collection.title, collection.tabs.map { ShareData(url = it.url, title = it.title) } ) metrics.track(Event.CollectionShared) } override fun handleDeleteCollectionTapped(collection: TabCollection) { removeCollectionWithUndo(collection) } override fun handleOpenInPrivateTabClicked(topSite: TopSite) { metrics.track( if (topSite is TopSite.Provided) { Event.TopSiteOpenContileInPrivateTab } else { Event.TopSiteOpenInPrivateTab } ) with(activity) { browsingModeManager.mode = BrowsingMode.Private openToBrowserAndLoad( searchTermOrURL = topSite.url, newTab = true, from = BrowserDirection.FromHome ) } } override fun handlePrivateBrowsingLearnMoreClicked() { dismissSearchDialogIfDisplayed() activity.openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(PRIVATE_BROWSING_MYTHS), newTab = true, from = BrowserDirection.FromHome ) } @SuppressLint("InflateParams") override fun handleRenameTopSiteClicked(topSite: TopSite) { activity.let { val customLayout = LayoutInflater.from(it).inflate(R.layout.top_sites_rename_dialog, null) val topSiteLabelEditText: EditText = customLayout.findViewById(R.id.top_site_title) topSiteLabelEditText.setText(topSite.title) AlertDialog.Builder(it).apply { setTitle(R.string.rename_top_site) setView(customLayout) setPositiveButton(R.string.top_sites_rename_dialog_ok) { dialog, _ -> viewLifecycleScope.launch(Dispatchers.IO) { with(activity.components.useCases.topSitesUseCase) { updateTopSites( topSite, topSiteLabelEditText.text.toString(), topSite.url ) } } dialog.dismiss() } setNegativeButton(R.string.top_sites_rename_dialog_cancel) { dialog, _ -> dialog.cancel() } }.show().also { topSiteLabelEditText.setSelection(0, topSiteLabelEditText.text.length) topSiteLabelEditText.showKeyboard() } } } override fun handleRemoveTopSiteClicked(topSite: TopSite) { metrics.track(Event.TopSiteRemoved) when (topSite.url) { SupportUtils.POCKET_TRENDING_URL -> metrics.track(Event.PocketTopSiteRemoved) SupportUtils.GOOGLE_URL -> metrics.track(Event.GoogleTopSiteRemoved) SupportUtils.BAIDU_URL -> metrics.track(Event.BaiduTopSiteRemoved) } viewLifecycleScope.launch(Dispatchers.IO) { with(activity.components.useCases.topSitesUseCase) { removeTopSites(topSite) } } } override fun handleRenameCollectionTapped(collection: TabCollection) { showCollectionCreationFragment( step = SaveCollectionStep.RenameCollection, selectedTabCollectionId = collection.id ) metrics.track(Event.CollectionRenamePressed) } override fun handleSelectTopSite(topSite: TopSite, position: Int) { dismissSearchDialogIfDisplayed() metrics.track(Event.TopSiteOpenInNewTab) metrics.track( when (topSite) { is TopSite.Default -> Event.TopSiteOpenDefault is TopSite.Frecent -> Event.TopSiteOpenFrecent is TopSite.Pinned -> Event.TopSiteOpenPinned is TopSite.Provided -> Event.TopSiteOpenProvided.also { submitTopSitesImpressionPing(topSite, position) } } ) when (topSite.url) { SupportUtils.GOOGLE_URL -> metrics.track(Event.TopSiteOpenGoogle) SupportUtils.BAIDU_URL -> metrics.track(Event.TopSiteOpenBaidu) SupportUtils.POCKET_TRENDING_URL -> metrics.track(Event.PocketTopSiteClicked) } val availableEngines = getAvailableSearchEngines() val searchAccessPoint = Event.PerformedSearch.SearchAccessPoint.TOPSITE val event = availableEngines.firstOrNull { engine -> engine.resultUrls.firstOrNull { it.contains(topSite.url) } != null }?.let { searchEngine -> searchAccessPoint.let { sap -> MetricsUtils.createSearchEvent(searchEngine, store, sap) } } event?.let { activity.metrics.track(it) } val tabId = addTabUseCase.invoke( url = appendSearchAttributionToUrlIfNeeded(topSite.url), selectTab = true, startLoading = true ) if (settings.openNextTabInDesktopMode) { activity.handleRequestDesktopMode(tabId) } activity.openToBrowser(BrowserDirection.FromHome) } @VisibleForTesting internal fun submitTopSitesImpressionPing(topSite: TopSite.Provided, position: Int) { metrics.track( Event.TopSiteContileClick( position = position + 1, source = Event.TopSiteContileClick.Source.NEWTAB ) ) topSite.id?.let { TopSites.contileTileId.set(it) } topSite.title?.let { TopSites.contileAdvertiser.set(it.lowercase()) } TopSites.contileReportingUrl.set(topSite.clickUrl) Pings.topsitesImpression.submit() } override fun handleTopSiteSettingsClicked() { metrics.track(Event.TopSiteContileSettings) navController.nav( R.id.homeFragment, HomeFragmentDirections.actionGlobalHomeSettingsFragment() ) } override fun handleSponsorPrivacyClicked() { metrics.track(Event.TopSiteContilePrivacy) activity.openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SPONSOR_PRIVACY), newTab = true, from = BrowserDirection.FromHome ) } @VisibleForTesting internal fun getAvailableSearchEngines() = activity.components.core.store.state.search.searchEngines + activity.components.core.store.state.search.availableSearchEngines /** * Append a search attribution query to any provided search engine URL based on the * user's current region. */ private fun appendSearchAttributionToUrlIfNeeded(url: String): String { if (url == SupportUtils.GOOGLE_URL) { store.state.search.region?.let { region -> return when (region.current) { "US" -> SupportUtils.GOOGLE_US_URL else -> SupportUtils.GOOGLE_XX_URL } } } return url } private fun dismissSearchDialogIfDisplayed() { if (navController.currentDestination?.id == R.id.searchDialogFragment) { navController.navigateUp() } } override fun handleStartBrowsingClicked() { hideOnboarding() } override fun handleCustomizeHomeTapped() { val directions = HomeFragmentDirections.actionGlobalHomeSettingsFragment() navController.nav(navController.currentDestination?.id, directions) metrics.track(Event.HomeScreenCustomizedHomeClicked) } override fun handleShowOnboardingDialog() { if (FeatureFlags.showHomeOnboarding) { navController.nav( R.id.homeFragment, HomeFragmentDirections.actionGlobalHomeOnboardingDialog() ) } } override fun handleReadPrivacyNoticeClicked() { activity.openToBrowserAndLoad( searchTermOrURL = SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE), newTab = true, from = BrowserDirection.FromHome ) } override fun handleToggleCollectionExpanded(collection: TabCollection, expand: Boolean) { appStore.dispatch(AppAction.CollectionExpanded(collection, expand)) } private fun showTabTrayCollectionCreation() { val directions = HomeFragmentDirections.actionGlobalTabsTrayFragment( enterMultiselect = true ) navController.nav(R.id.homeFragment, directions) } private fun showCollectionCreationFragment( step: SaveCollectionStep, selectedTabIds: Array? = null, selectedTabCollectionId: Long? = null ) { if (navController.currentDestination?.id == R.id.collectionCreationFragment) return // Only register the observer right before moving to collection creation registerCollectionStorageObserver() val tabIds = store.state .getNormalOrPrivateTabs(private = activity.browsingModeManager.mode.isPrivate) .map { session -> session.id } .toList() .toTypedArray() val directions = HomeFragmentDirections.actionGlobalCollectionCreationFragment( tabIds = tabIds, saveCollectionStep = step, selectedTabIds = selectedTabIds, selectedTabCollectionId = selectedTabCollectionId ?: -1 ) navController.nav(R.id.homeFragment, directions) } override fun handleCreateCollection() { showTabTrayCollectionCreation() } override fun handleRemoveCollectionsPlaceholder() { settings.showCollectionsPlaceholderOnHome = false appStore.dispatch(AppAction.RemoveCollectionsPlaceholder) } private fun showShareFragment(shareSubject: String, data: List) { val directions = HomeFragmentDirections.actionGlobalShareFragment( shareSubject = shareSubject, data = data.toTypedArray() ) navController.nav(R.id.homeFragment, directions) } override fun handlePasteAndGo(clipboardText: String) { val searchEngine = store.state.search.selectedOrDefaultSearchEngine activity.openToBrowserAndLoad( searchTermOrURL = clipboardText, newTab = true, from = BrowserDirection.FromHome, engine = searchEngine ) val event = if (clipboardText.isUrl() || searchEngine == null) { Event.EnteredUrl(false) } else { val searchAccessPoint = Event.PerformedSearch.SearchAccessPoint.ACTION searchAccessPoint.let { sap -> MetricsUtils.createSearchEvent( searchEngine, store, sap ) } } event?.let { activity.metrics.track(it) } } override fun handlePaste(clipboardText: String) { val directions = HomeFragmentDirections.actionGlobalSearchDialog( sessionId = null, pastedText = clipboardText ) navController.nav(R.id.homeFragment, directions) } override fun handleMessageClicked(message: Message) { messageController.onMessagePressed(message) } override fun handleMessageClosed(message: Message) { messageController.onMessageDismissed(message) } override fun handleMessageDisplayed(message: Message) { messageController.onMessageDisplayed(message) } override fun handlePrivateModeButtonClicked( newMode: BrowsingMode, userHasBeenOnboarded: Boolean ) { if (newMode == BrowsingMode.Private) { activity.settings().incrementNumTimesPrivateModeOpened() } if (userHasBeenOnboarded) { appStore.dispatch( AppAction.ModeChange(Mode.fromBrowsingMode(newMode)) ) if (navController.currentDestination?.id == R.id.searchDialogFragment) { navController.navigate( BrowserFragmentDirections.actionGlobalSearchDialog( sessionId = null ) ) } } } override fun handleReportSessionMetrics(state: AppState) { with(metrics) { track( if (state.recentTabs.isEmpty()) { Event.RecentTabsSectionIsNotVisible } else { Event.RecentTabsSectionIsVisible } ) track(Event.RecentBookmarkCount(state.recentBookmarks.size)) } } }