fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt

512 lines
20 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.browser
import android.content.Context
import android.os.StrictMode
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.BrowserThumbnails
import mozilla.components.browser.toolbar.BrowserToolbar
import mozilla.components.concept.engine.permission.SitePermissions
import mozilla.components.feature.app.links.AppLinksUseCases
import mozilla.components.feature.contextmenu.ContextMenuCandidate
import mozilla.components.feature.readerview.ReaderViewFeature
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.WindowFeature
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.service.glean.private.NoExtras
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
import org.mozilla.fenix.GleanMetrics.ReaderMode
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.toolbar.BrowserToolbarView
import org.mozilla.fenix.components.toolbar.ToolbarMenu
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.dialog.CookieBannerReEngagementDialogUtils
import org.mozilla.fenix.shortcut.PwaOnboardingObserver
import org.mozilla.fenix.theme.ThemeManager
/**
* Fragment used for browsing the web within the main app.
*/
@Suppress("TooManyFunctions", "LargeClass")
class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
private val windowFeature = ViewBoundFeatureWrapper<WindowFeature>()
private val openInAppOnboardingObserver = ViewBoundFeatureWrapper<OpenInAppOnboardingObserver>()
private var readerModeAvailable = false
private var pwaOnboardingObserver: PwaOnboardingObserver? = null
private var forwardAction: BrowserToolbar.TwoStateButton? = null
private var backAction: BrowserToolbar.TwoStateButton? = null
private var refreshAction: BrowserToolbar.TwoStateButton? = null
private var isTablet: Boolean = false
@Suppress("LongMethod")
override fun initializeUI(view: View, tab: SessionState) {
super.initializeUI(view, tab)
val context = requireContext()
val components = context.components
if (context.settings().isSwipeToolbarToSwitchTabsEnabled) {
binding.gestureLayout.addGestureListener(
ToolbarGestureHandler(
activity = requireActivity(),
contentLayout = binding.browserLayout,
tabPreview = binding.tabPreview,
toolbarLayout = browserToolbarView.view,
store = components.core.store,
selectTabUseCase = components.useCases.tabsUseCases.selectTab,
),
)
}
val homeAction = BrowserToolbar.Button(
imageDrawable = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_home,
)!!,
contentDescription = context.getString(R.string.browser_toolbar_home),
iconTintColorResource = ThemeManager.resolveAttribute(R.attr.textPrimary, context),
listener = browserToolbarInteractor::onHomeButtonClicked,
)
browserToolbarView.view.addNavigationAction(homeAction)
updateToolbarActions(isTablet = resources.getBoolean(R.bool.tablet))
val readerModeAction =
BrowserToolbar.ToggleButton(
image = AppCompatResources.getDrawable(
context,
R.drawable.ic_readermode,
)!!,
imageSelected =
AppCompatResources.getDrawable(
context,
R.drawable.ic_readermode_selected,
)!!,
contentDescription = context.getString(R.string.browser_menu_read),
contentDescriptionSelected = context.getString(R.string.browser_menu_read_close),
visible = {
readerModeAvailable
},
selected = getCurrentTab()?.let {
activity?.components?.core?.store?.state?.findTab(it.id)?.readerState?.active
} ?: false,
listener = browserToolbarInteractor::onReaderModePressed,
)
browserToolbarView.view.addPageAction(readerModeAction)
thumbnailsFeature.set(
feature = BrowserThumbnails(context, binding.engineView, components.core.store),
owner = this,
view = view,
)
readerViewFeature.set(
feature = components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
ReaderViewFeature(
context,
components.core.engine,
components.core.store,
binding.readerViewControlsBar,
) { available, active ->
if (available) {
ReaderMode.available.record(NoExtras())
}
readerModeAvailable = available
readerModeAction.setSelected(active)
safeInvalidateBrowserToolbarView()
}
},
owner = this,
view = view,
)
windowFeature.set(
feature = WindowFeature(
store = components.core.store,
tabsUseCases = components.useCases.tabsUseCases,
),
owner = this,
view = view,
)
if (context.settings().shouldShowOpenInAppCfr) {
openInAppOnboardingObserver.set(
feature = OpenInAppOnboardingObserver(
context = context,
store = context.components.core.store,
lifecycleOwner = this,
navController = findNavController(),
settings = context.settings(),
appLinksUseCases = context.components.useCases.appLinksUseCases,
container = binding.browserLayout as ViewGroup,
shouldScrollWithTopToolbar = !context.settings().shouldUseBottomToolbar,
),
owner = this,
view = view,
)
}
if (!context.settings().shouldUseCookieBanner && !context.settings().userOptOutOfReEngageCookieBannerDialog) {
observeCookieBannerHandlingState(context.components.core.store)
}
}
override fun onUpdateToolbarForConfigurationChange(toolbar: BrowserToolbarView) {
super.onUpdateToolbarForConfigurationChange(toolbar)
updateToolbarActions(isTablet = resources.getBoolean(R.bool.tablet))
}
@VisibleForTesting
internal fun updateToolbarActions(isTablet: Boolean) {
if (isTablet == this.isTablet) return
if (isTablet) {
addTabletActions(requireContext())
} else {
removeTabletActions()
}
this.isTablet = isTablet
}
@Suppress("LongMethod")
private fun addTabletActions(context: Context) {
val enableTint = ThemeManager.resolveAttribute(R.attr.textPrimary, context)
val disableTint = ThemeManager.resolveAttribute(R.attr.textDisabled, context)
if (backAction == null) {
backAction = BrowserToolbar.TwoStateButton(
primaryImage = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_back,
)!!,
primaryContentDescription = context.getString(R.string.browser_menu_back),
primaryImageTintResource = enableTint,
isInPrimaryState = { getCurrentTab()?.content?.canGoBack ?: false },
secondaryImageTintResource = disableTint,
disableInSecondaryState = true,
longClickListener = {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Back(viewHistory = true),
)
},
listener = {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Back(viewHistory = false),
)
},
)
}
backAction?.let {
browserToolbarView.view.addNavigationAction(it)
}
if (forwardAction == null) {
forwardAction = BrowserToolbar.TwoStateButton(
primaryImage = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_forward,
)!!,
primaryContentDescription = context.getString(R.string.browser_menu_forward),
primaryImageTintResource = enableTint,
isInPrimaryState = { getCurrentTab()?.content?.canGoForward ?: false },
secondaryImageTintResource = disableTint,
disableInSecondaryState = true,
longClickListener = {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Forward(viewHistory = true),
)
},
listener = {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Forward(viewHistory = false),
)
},
)
}
forwardAction?.let {
browserToolbarView.view.addNavigationAction(it)
}
if (refreshAction == null) {
refreshAction = BrowserToolbar.TwoStateButton(
primaryImage = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_refresh,
)!!,
primaryContentDescription = context.getString(R.string.browser_menu_refresh),
primaryImageTintResource = enableTint,
isInPrimaryState = {
getCurrentTab()?.content?.loading == false
},
secondaryImage = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_stop,
)!!,
secondaryContentDescription = context.getString(R.string.browser_menu_stop),
disableInSecondaryState = false,
longClickListener = {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Reload(bypassCache = true),
)
},
listener = {
if (getCurrentTab()?.content?.loading == true) {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Stop)
} else {
browserToolbarInteractor.onBrowserToolbarMenuItemTapped(
ToolbarMenu.Item.Reload(bypassCache = false),
)
}
},
)
}
refreshAction?.let {
browserToolbarView.view.addNavigationAction(it)
}
browserToolbarView.view.invalidateActions()
}
private fun removeTabletActions() {
forwardAction?.let {
browserToolbarView.view.removeNavigationAction(it)
}
backAction?.let {
browserToolbarView.view.removeNavigationAction(it)
}
refreshAction?.let {
browserToolbarView.view.removeNavigationAction(it)
}
browserToolbarView.view.invalidateActions()
}
override fun onStart() {
super.onStart()
val context = requireContext()
val settings = context.settings()
if (!settings.userKnowsAboutPwas) {
pwaOnboardingObserver = PwaOnboardingObserver(
store = context.components.core.store,
lifecycleOwner = this,
navController = findNavController(),
settings = settings,
webAppUseCases = context.components.useCases.webAppUseCases,
).also {
it.start()
}
}
subscribeToTabCollections()
updateLastBrowseActivity()
}
override fun onStop() {
super.onStop()
updateLastBrowseActivity()
updateHistoryMetadata()
pwaOnboardingObserver?.stop()
}
private fun updateHistoryMetadata() {
getCurrentTab()?.let { tab ->
(tab as? TabSessionState)?.historyMetadata?.let {
requireComponents.core.historyMetadataService.updateMetadata(it, tab)
}
}
}
private fun subscribeToTabCollections() {
Observer<List<TabCollection>> {
requireComponents.core.tabCollectionStorage.cachedTabCollections = it
}.also { observer ->
requireComponents.core.tabCollectionStorage.getCollections()
.observe(viewLifecycleOwner, observer)
}
}
override fun onResume() {
super.onResume()
requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
}
override fun onBackPressed(): Boolean {
return readerViewFeature.onBackPressed() || super.onBackPressed()
}
override fun navToQuickSettingsSheet(tab: SessionState, sitePermissions: SitePermissions?) {
val useCase = requireComponents.useCases.trackingProtectionUseCases
FxNimbus.features.cookieBanners.recordExposure()
useCase.containsException(tab.id) { hasTrackingProtectionException ->
lifecycleScope.launch(Dispatchers.Main) {
val cookieBannersStorage = requireComponents.core.cookieBannersStorage
val hasCookieBannerException =
if (requireContext().settings().shouldUseCookieBanner) {
withContext(Dispatchers.IO) {
cookieBannersStorage.hasException(
tab.content.url,
tab.content.private,
)
}
} else {
false
}
runIfFragmentIsAttached {
val isTrackingProtectionEnabled =
tab.trackingProtection.enabled && !hasTrackingProtectionException
val directions =
BrowserFragmentDirections.actionBrowserFragmentToQuickSettingsSheetDialogFragment(
sessionId = tab.id,
url = tab.content.url,
title = tab.content.title,
isSecured = tab.content.securityInfo.secure,
sitePermissions = sitePermissions,
gravity = getAppropriateLayoutGravity(),
certificateName = tab.content.securityInfo.issuer,
permissionHighlights = tab.content.permissionHighlights,
isTrackingProtectionEnabled = isTrackingProtectionEnabled,
isCookieHandlingEnabled = !hasCookieBannerException,
)
nav(R.id.browserFragment, directions)
}
}
}
}
private val collectionStorageObserver = object : TabCollectionStorage.Observer {
override fun onCollectionCreated(
title: String,
sessions: List<TabSessionState>,
id: Long?,
) {
showTabSavedToCollectionSnackbar(sessions.size, true)
}
override fun onTabsAdded(tabCollection: TabCollection, sessions: List<TabSessionState>) {
showTabSavedToCollectionSnackbar(sessions.size)
}
private fun showTabSavedToCollectionSnackbar(
tabSize: Int,
isNewCollection: Boolean = false,
) {
view?.let { view ->
val messageStringRes = when {
isNewCollection -> {
R.string.create_collection_tabs_saved_new_collection
}
tabSize > 1 -> {
R.string.create_collection_tabs_saved
}
else -> {
R.string.create_collection_tab_saved
}
}
FenixSnackbar.make(
view = binding.browserLayout,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = true,
)
.setText(view.context.getString(messageStringRes))
.setAction(requireContext().getString(R.string.create_collection_view)) {
findNavController().navigate(
BrowserFragmentDirections.actionGlobalHome(
focusOnAddressBar = false,
scrollToCollection = true,
),
)
}
.show()
}
}
}
override fun getContextMenuCandidates(
context: Context,
view: View,
): List<ContextMenuCandidate> {
val contextMenuCandidateAppLinksUseCases = AppLinksUseCases(
requireContext(),
{ true },
)
return ContextMenuCandidate.defaultCandidates(
context,
context.components.useCases.tabsUseCases,
context.components.useCases.contextMenuUseCases,
view,
FenixSnackbarDelegate(view),
) + ContextMenuCandidate.createOpenInExternalAppCandidate(
requireContext(),
contextMenuCandidateAppLinksUseCases,
)
}
/**
* Updates the last time the user was active on the [BrowserFragment].
* This is useful to determine if the user has to start on the [HomeFragment]
* or it should go directly to the [BrowserFragment].
*/
@VisibleForTesting
internal fun updateLastBrowseActivity() {
requireContext().settings().lastBrowseActivity = System.currentTimeMillis()
}
private fun observeCookieBannerHandlingState(store: BrowserStore) {
consumeFlow(store) { flow ->
flow.mapNotNull { state ->
state.findCustomTabOrSelectedTab(customTabSessionId)
}.ifAnyChanged { tab ->
arrayOf(
tab.cookieBanner,
)
}.collect {
CookieBannerReEngagementDialogUtils.tryToShowReEngagementDialog(
settings = requireContext().settings(),
status = it.cookieBanner,
navController = findNavController(),
)
}
}
}
}