You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt

664 lines
23 KiB
Kotlin

/* 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.search.SearchEngine
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 mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.GleanMetrics.Collections
import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.HomeScreen
import org.mozilla.fenix.GleanMetrics.Pings
import org.mozilla.fenix.GleanMetrics.Pocket
import org.mozilla.fenix.GleanMetrics.RecentBookmarks
import org.mozilla.fenix.GleanMetrics.RecentTabs
import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.BrowserAnimator
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.MetricsUtils
import org.mozilla.fenix.ext.components
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.onboarding.WallpaperOnboardingDialogFragment.Companion.THUMBNAILS_SELECTION_COUNT
import org.mozilla.fenix.search.toolbar.SearchSelectorInteractor
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.wallpapers.Wallpaper
import org.mozilla.fenix.wallpapers.WallpaperState
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 [MessageCardInteractor.onMessageClicked]
*/
fun handleMessageClicked(message: Message)
/**
* @see [MessageCardInteractor.onMessageClosedClicked]
*/
fun handleMessageClosed(message: Message)
/**
* @see [TabSessionInteractor.onPrivateModeButtonClicked]
*/
fun handlePrivateModeButtonClicked(newMode: BrowsingMode, userHasBeenOnboarded: Boolean)
/**
* @see [CustomizeHomeIteractor.openCustomizeHomePage]
*/
fun handleCustomizeHomeTapped()
/**
* @see [OnboardingInteractor.showWallpapersOnboardingDialog]
*/
fun handleShowWallpapersOnboardingDialog(state: WallpaperState): Boolean
/**
* @see [SessionControlInteractor.reportSessionMetrics]
*/
fun handleReportSessionMetrics(state: AppState)
/**
* @see [SearchSelectorInteractor.onMenuItemTapped]
*/
fun handleMenuItemTapped(item: SearchSelectorMenu.Item)
}
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
class DefaultSessionControlController(
private val activity: HomeActivity,
private val settings: Settings,
private val engine: Engine,
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) {
Collections.addTabButton.record(NoExtras())
showCollectionCreationFragment(
step = SaveCollectionStep.SelectTabs,
selectedTabCollectionId = collection.id,
)
}
override fun handleCollectionOpenTabClicked(tab: ComponentTab) {
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,
)
},
)
Collections.tabRestored.record(NoExtras())
}
override fun handleCollectionOpenTabsTapped(collection: TabCollection) {
restoreUseCase.invoke(
activity,
engine,
collection,
onFailure = { url ->
addTabUseCase.invoke(url)
},
)
showTabTray()
Collections.allTabsRestored.record(NoExtras())
}
override fun handleCollectionRemoveTab(
collection: TabCollection,
tab: ComponentTab,
wasSwiped: Boolean,
) {
Collections.tabRemoved.record(NoExtras())
if (collection.tabs.size == 1) {
removeCollectionWithUndo(collection)
} else {
viewLifecycleScope.launch {
tabCollectionStorage.removeTabFromCollection(collection, tab)
}
}
}
override fun handleCollectionShareTabsClicked(collection: TabCollection) {
showShareFragment(
collection.title,
collection.tabs.map { ShareData(url = it.url, title = it.title) },
)
Collections.shared.record(NoExtras())
}
override fun handleDeleteCollectionTapped(collection: TabCollection) {
removeCollectionWithUndo(collection)
Collections.removed.record(NoExtras())
}
override fun handleOpenInPrivateTabClicked(topSite: TopSite) {
if (topSite is TopSite.Provided) {
TopSites.openContileInPrivateTab.record(NoExtras())
} else {
TopSites.openInPrivateTab.record(NoExtras())
}
with(activity) {
browsingModeManager.mode = BrowsingMode.Private
openToBrowserAndLoad(
searchTermOrURL = topSite.url,
newTab = true,
from = BrowserDirection.FromHome,
)
}
}
override fun handlePrivateBrowsingLearnMoreClicked() {
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) {
TopSites.remove.record(NoExtras())
when (topSite.url) {
SupportUtils.POCKET_TRENDING_URL -> Pocket.pocketTopSiteRemoved.record(NoExtras())
SupportUtils.GOOGLE_URL -> TopSites.googleTopSiteRemoved.record(NoExtras())
SupportUtils.BAIDU_URL -> TopSites.baiduTopSiteRemoved.record(NoExtras())
}
viewLifecycleScope.launch(Dispatchers.IO) {
with(activity.components.useCases.topSitesUseCase) {
removeTopSites(topSite)
}
}
}
override fun handleRenameCollectionTapped(collection: TabCollection) {
showCollectionCreationFragment(
step = SaveCollectionStep.RenameCollection,
selectedTabCollectionId = collection.id,
)
Collections.renameButton.record(NoExtras())
}
override fun handleSelectTopSite(topSite: TopSite, position: Int) {
TopSites.openInNewTab.record(NoExtras())
when (topSite) {
is TopSite.Default -> TopSites.openDefault.record(NoExtras())
is TopSite.Frecent -> TopSites.openFrecency.record(NoExtras())
is TopSite.Pinned -> TopSites.openPinned.record(NoExtras())
is TopSite.Provided -> TopSites.openContileTopSite.record(NoExtras()).also {
submitTopSitesImpressionPing(topSite, position)
}
}
when (topSite.url) {
SupportUtils.GOOGLE_URL -> TopSites.openGoogleSearchAttribution.record(NoExtras())
SupportUtils.BAIDU_URL -> TopSites.openBaiduSearchAttribution.record(NoExtras())
SupportUtils.POCKET_TRENDING_URL -> Pocket.pocketTopSiteClicked.record(NoExtras())
}
val availableEngines: List<SearchEngine> = getAvailableSearchEngines()
val searchAccessPoint = MetricsUtils.Source.TOPSITE
availableEngines.firstOrNull { engine ->
engine.resultUrls.firstOrNull { it.contains(topSite.url) } != null
}?.let { searchEngine ->
MetricsUtils.recordSearchMetrics(
searchEngine,
searchEngine == store.state.search.selectedOrDefaultSearchEngine,
searchAccessPoint,
)
}
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) {
TopSites.contileClick.record(
TopSites.ContileClickExtra(
position = position + 1,
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() {
TopSites.contileSettings.record(NoExtras())
navController.nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalHomeSettingsFragment(),
)
}
override fun handleSponsorPrivacyClicked() {
TopSites.contileSponsorsAndPrivacy.record(NoExtras())
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
}
override fun handleStartBrowsingClicked() {
hideOnboarding()
}
override fun handleCustomizeHomeTapped() {
val directions = HomeFragmentDirections.actionGlobalHomeSettingsFragment()
navController.nav(navController.currentDestination?.id, directions)
HomeScreen.customizeHomeClicked.record(NoExtras())
}
override fun handleShowWallpapersOnboardingDialog(state: WallpaperState): Boolean {
return if (activity.browsingModeManager.mode.isPrivate) {
false
} else {
state.availableWallpapers.filter { wallpaper ->
wallpaper.thumbnailFileState == Wallpaper.ImageFileState.Downloaded
}.size.let { downloadedCount ->
// We only display the dialog if enough thumbnails have been downloaded for it.
downloadedCount >= THUMBNAILS_SELECTION_COUNT
}.also { showOnboarding ->
if (showOnboarding) {
navController.nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalWallpaperOnboardingDialog(),
)
}
}
}
}
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<String>? = 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<ShareData>) {
val directions = HomeFragmentDirections.actionGlobalShareFragment(
sessionId = store.state.selectedTabId,
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,
)
if (clipboardText.isUrl() || searchEngine == null) {
Events.enteredUrl.record(Events.EnteredUrlExtra(autocomplete = false))
} else {
val searchAccessPoint = MetricsUtils.Source.ACTION
MetricsUtils.recordSearchMetrics(
searchEngine,
searchEngine == store.state.search.selectedOrDefaultSearchEngine,
searchAccessPoint,
)
}
}
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 handlePrivateModeButtonClicked(
newMode: BrowsingMode,
userHasBeenOnboarded: Boolean,
) {
if (newMode == BrowsingMode.Private) {
activity.settings().incrementNumTimesPrivateModeOpened()
}
if (userHasBeenOnboarded) {
appStore.dispatch(
AppAction.ModeChange(Mode.fromBrowsingMode(newMode)),
)
}
}
override fun handleReportSessionMetrics(state: AppState) {
if (state.recentTabs.isEmpty()) {
RecentTabs.sectionVisible.set(false)
} else {
RecentTabs.sectionVisible.set(true)
}
RecentBookmarks.recentBookmarksCount.set(state.recentBookmarks.size.toLong())
}
override fun handleMenuItemTapped(item: SearchSelectorMenu.Item) {
when (item) {
SearchSelectorMenu.Item.SearchSettings -> {
navController.nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalSearchEngineFragment(),
)
}
is SearchSelectorMenu.Item.SearchEngine -> {
val directions = HomeFragmentDirections.actionGlobalSearchDialog(
sessionId = null,
searchEngine = item.searchEngine.id,
)
navController.nav(
R.id.homeFragment,
directions,
BrowserAnimator.getToolbarNavOptions(activity),
)
}
}
}
}