/* 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.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_browser.* import kotlinx.android.synthetic.main.fragment_browser.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.state.CustomTabSessionState import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaWebChannelFeature import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.contextmenu.ContextMenuCandidate import mozilla.components.feature.contextmenu.ContextMenuFeature import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.manager.FetchDownloadManager import mozilla.components.feature.downloads.share.ShareDownloadFeature import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature import mozilla.components.feature.privatemode.feature.SecureWindowFeature import mozilla.components.feature.prompts.PromptFeature import mozilla.components.feature.prompts.share.ShareDelegate import mozilla.components.feature.readerview.ReaderViewFeature import mozilla.components.feature.search.SearchFeature import mozilla.components.feature.session.FullScreenFeature import mozilla.components.feature.session.PictureInPictureFeature import mozilla.components.feature.session.SessionFeature import mozilla.components.feature.session.SwipeRefreshFeature import mozilla.components.feature.sitepermissions.SitePermissions import mozilla.components.feature.sitepermissions.SitePermissionsFeature import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.flowScoped import mozilla.components.service.sync.logins.DefaultLoginValidationDelegate import mozilla.components.support.base.feature.PermissionsFeature import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.view.exitImmersiveModeIfNeeded import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.OnBackLongPressedListener import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.readermode.DefaultReaderModeController import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FindInPageIntegration import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.toolbar.BrowserFragmentState import org.mozilla.fenix.components.toolbar.BrowserFragmentStore import org.mozilla.fenix.components.toolbar.BrowserInteractor import org.mozilla.fenix.components.toolbar.BrowserToolbarView import org.mozilla.fenix.components.toolbar.BrowserToolbarViewInteractor import org.mozilla.fenix.components.toolbar.DefaultBrowserToolbarController import org.mozilla.fenix.components.toolbar.DefaultBrowserToolbarMenuController import org.mozilla.fenix.components.toolbar.ToolbarIntegration import org.mozilla.fenix.downloads.DownloadService import org.mozilla.fenix.downloads.DynamicDownloadDialog import org.mozilla.fenix.ext.accessibilityManager import org.mozilla.fenix.ext.breadcrumb import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.enterToImmersiveMode import org.mozilla.fenix.ext.getPreferenceKey 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.settings import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.SharedViewModel import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration import java.lang.ref.WeakReference import mozilla.components.feature.session.behavior.EngineViewBrowserToolbarBehavior import mozilla.components.feature.webauthn.WebAuthnFeature import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.feature.session.behavior.ToolbarPosition as MozacToolbarPosition /** * Base fragment extended by [BrowserFragment]. * This class only contains shared code focused on the main browsing content. * UI code specific to the app or to custom tabs can be found in the subclasses. */ @ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "LargeClass") abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler, OnBackLongPressedListener, AccessibilityManager.AccessibilityStateChangeListener { private lateinit var browserFragmentStore: BrowserFragmentStore private lateinit var browserAnimator: BrowserAnimator private var _browserInteractor: BrowserToolbarViewInteractor? = null protected val browserInteractor: BrowserToolbarViewInteractor get() = _browserInteractor!! @VisibleForTesting @Suppress("VariableNaming") internal var _browserToolbarView: BrowserToolbarView? = null @VisibleForTesting internal val browserToolbarView: BrowserToolbarView get() = _browserToolbarView!! protected val readerViewFeature = ViewBoundFeatureWrapper() protected val thumbnailsFeature = ViewBoundFeatureWrapper() private val sessionFeature = ViewBoundFeatureWrapper() private val contextMenuFeature = ViewBoundFeatureWrapper() private val downloadsFeature = ViewBoundFeatureWrapper() private val shareDownloadsFeature = ViewBoundFeatureWrapper() private val appLinksFeature = ViewBoundFeatureWrapper() private val promptsFeature = ViewBoundFeatureWrapper() private val findInPageIntegration = ViewBoundFeatureWrapper() private val toolbarIntegration = ViewBoundFeatureWrapper() private val sitePermissionsFeature = ViewBoundFeatureWrapper() private val fullScreenFeature = ViewBoundFeatureWrapper() private val swipeRefreshFeature = ViewBoundFeatureWrapper() private val webchannelIntegration = ViewBoundFeatureWrapper() private val sitePermissionWifiIntegration = ViewBoundFeatureWrapper() private val secureWindowFeature = ViewBoundFeatureWrapper() private var fullScreenMediaSessionFeature = ViewBoundFeatureWrapper() private val searchFeature = ViewBoundFeatureWrapper() private val webAuthnFeature = ViewBoundFeatureWrapper() private var pipFeature: PictureInPictureFeature? = null var customTabSessionId: String? = null @VisibleForTesting internal var browserInitialized: Boolean = false private var initUIJob: Job? = null protected var webAppToolbarShouldBeVisible = true private val sharedViewModel: SharedViewModel by activityViewModels() private val homeViewModel: HomeScreenViewModel by activityViewModels() @VisibleForTesting internal val onboarding by lazy { FenixOnboarding(requireContext()) } @CallSuper override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID) // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( message = "onCreateView()", data = mapOf( "customTabSessionId" to customTabSessionId.toString() ) ) val view = inflater.inflate(R.layout.fragment_browser, container, false) val activity = activity as HomeActivity activity.themeManager.applyStatusBarTheme(activity) browserFragmentStore = StoreProvider.get(this) { BrowserFragmentStore( BrowserFragmentState() ) } return view } final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initializeUI(view) if (customTabSessionId == null) { // We currently only need this observer to navigate to home // in case all tabs have been removed on startup. No need to // this if we have a known session to display. observeRestoreComplete(requireComponents.core.store, findNavController()) } observeTabSelection(requireComponents.core.store) if (!onboarding.userHasBeenOnboarded()) { observeTabSource(requireComponents.core.store) } requireContext().accessibilityManager.addAccessibilityStateChangeListener(this) } private fun initializeUI(view: View) { val tab = getCurrentTab() browserInitialized = if (tab != null) { initializeUI(view, tab) true } else { false } } @Suppress("ComplexMethod", "LongMethod") @CallSuper internal open fun initializeUI(view: View, tab: SessionState) { val context = requireContext() val store = context.components.core.store val activity = requireActivity() as HomeActivity val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) browserAnimator = BrowserAnimator( fragment = WeakReference(this), engineView = WeakReference(engineView), swipeRefresh = WeakReference(swipeRefresh), viewLifecycleScope = WeakReference(viewLifecycleOwner.lifecycleScope) ).apply { beginAnimateInIfNecessary() } val openInFenixIntent = Intent(context, IntentReceiverActivity::class.java).apply { action = Intent.ACTION_VIEW putExtra(HomeActivity.OPEN_TO_BROWSER, true) } val readerMenuController = DefaultReaderModeController( readerViewFeature, view.readerViewControlsBar, isPrivate = activity.browsingModeManager.mode.isPrivate ) val browserToolbarController = DefaultBrowserToolbarController( store = store, tabsUseCases = requireComponents.useCases.tabsUseCases, activity = activity, navController = findNavController(), metrics = requireComponents.analytics.metrics, readerModeController = readerMenuController, engineView = engineView, homeViewModel = homeViewModel, customTabSessionId = customTabSessionId, onTabCounterClicked = { thumbnailsFeature.get()?.requestScreenshot() findNavController().nav( R.id.browserFragment, BrowserFragmentDirections.actionGlobalTabTrayDialogFragment() ) }, onCloseTab = { closedSession -> val closedTab = store.state.findTab(closedSession.id) ?: return@DefaultBrowserToolbarController val snackbarMessage = if (closedTab.content.private) { requireContext().getString(R.string.snackbar_private_tab_closed) } else { requireContext().getString(R.string.snackbar_tab_closed) } viewLifecycleOwner.lifecycleScope.allowUndo( requireView().browserLayout, snackbarMessage, requireContext().getString(R.string.snackbar_deleted_undo), { requireComponents.useCases.tabsUseCases.undo.invoke() }, paddedForBottomToolbar = true, operation = { } ) } ) val browserToolbarMenuController = DefaultBrowserToolbarMenuController( store = store, activity = activity, navController = findNavController(), metrics = requireComponents.analytics.metrics, settings = context.settings(), readerModeController = readerMenuController, sessionFeature = sessionFeature, findInPageLauncher = { findInPageIntegration.withFeature { it.launch() } }, swipeRefresh = swipeRefresh, browserAnimator = browserAnimator, customTabSessionId = customTabSessionId, openInFenixIntent = openInFenixIntent, bookmarkTapped = { url: String, title: String -> viewLifecycleOwner.lifecycleScope.launch { bookmarkTapped(url, title) } }, scope = viewLifecycleOwner.lifecycleScope, tabCollectionStorage = requireComponents.core.tabCollectionStorage, topSitesStorage = requireComponents.core.topSitesStorage, browserStore = store ) _browserInteractor = BrowserInteractor( browserToolbarController, browserToolbarMenuController ) _browserToolbarView = BrowserToolbarView( container = view.browserLayout, toolbarPosition = context.settings().toolbarPosition, interactor = browserInteractor, customTabSession = customTabSessionId?.let { store.state.findCustomTab(it) }, lifecycleOwner = viewLifecycleOwner ) toolbarIntegration.set( feature = browserToolbarView.toolbarIntegration, owner = this, view = view ) findInPageIntegration.set( feature = FindInPageIntegration( store = store, sessionId = customTabSessionId, stub = view.stubFindInPage, engineView = view.engineView, toolbar = browserToolbarView.view ), owner = this, view = view ) browserToolbarView.view.display.setOnSiteSecurityClickedListener { showQuickSettingsDialog() } browserToolbarView.view.display.setOnTrackingProtectionClickedListener { context.metrics.track(Event.TrackingProtectionIconPressed) showTrackingProtectionPanel() } contextMenuFeature.set( feature = ContextMenuFeature( fragmentManager = parentFragmentManager, store = store, candidates = getContextMenuCandidates(context, view.browserLayout), engineView = view.engineView, useCases = context.components.useCases.contextMenuUseCases, tabId = customTabSessionId ), owner = this, view = view ) val allowScreenshotsInPrivateMode = context.settings().allowScreenshotsInPrivateMode secureWindowFeature.set( feature = SecureWindowFeature( window = requireActivity().window, store = store, customTabId = customTabSessionId, isSecure = { !allowScreenshotsInPrivateMode && it.content.private } ), owner = this, view = view ) fullScreenMediaSessionFeature.set( feature = MediaSessionFullscreenFeature( requireActivity(), context.components.core.store ), owner = this, view = view ) val shareDownloadFeature = ShareDownloadFeature( context = context.applicationContext, httpClient = context.components.core.client, store = store, tabId = customTabSessionId ) val downloadFeature = DownloadsFeature( context.applicationContext, store = store, useCases = context.components.useCases.downloadUseCases, fragmentManager = childFragmentManager, tabId = customTabSessionId, downloadManager = FetchDownloadManager( context.applicationContext, store, DownloadService::class ), shouldForwardToThirdParties = { PreferenceManager.getDefaultSharedPreferences(context).getBoolean( context.getPreferenceKey(R.string.pref_key_external_download_manager), false ) }, promptsStyling = DownloadsFeature.PromptsStyling( gravity = Gravity.BOTTOM, shouldWidthMatchParent = true, positiveButtonBackgroundColor = ThemeManager.resolveAttribute( R.attr.accent, context ), positiveButtonTextColor = ThemeManager.resolveAttribute( R.attr.contrastText, context ), positiveButtonRadius = (resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat() ), onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS) } ) downloadFeature.onDownloadStopped = { downloadState, _, downloadJobStatus -> // If the download is just paused, don't show any in-app notification if (shouldShowCompletedDownloadDialog(downloadState, downloadJobStatus)) { saveDownloadDialogState( downloadState.sessionId, downloadState, downloadJobStatus ) val dynamicDownloadDialog = DynamicDownloadDialog( container = view.browserLayout, downloadState = downloadState, metrics = requireComponents.analytics.metrics, didFail = downloadJobStatus == DownloadState.Status.FAILED, tryAgain = downloadFeature::tryAgain, onCannotOpenFile = { FenixSnackbar.make( view = view.browserLayout, duration = Snackbar.LENGTH_SHORT, isDisplayedWithBrowserToolbar = true ) .setText(context.getString(R.string.mozac_feature_downloads_could_not_open_file)) .show() }, view = view.viewDynamicDownloadDialog, toolbarHeight = toolbarHeight, onDismiss = { sharedViewModel.downloadDialogState.remove(downloadState.sessionId) } ) dynamicDownloadDialog.show() browserToolbarView.expand() } } resumeDownloadDialogState( getCurrentTab()?.id, store, view, context, toolbarHeight ) shareDownloadsFeature.set( shareDownloadFeature, owner = this, view = view ) downloadsFeature.set( downloadFeature, owner = this, view = view ) pipFeature = PictureInPictureFeature( store = store, activity = requireActivity(), crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId ) appLinksFeature.set( feature = AppLinksFeature( context, store = store, sessionId = customTabSessionId, fragmentManager = parentFragmentManager, launchInApp = { context.settings().openLinksInExternalApp }, loadUrlUseCase = context.components.useCases.sessionUseCases.loadUrl ), owner = this, view = view ) promptsFeature.set( feature = PromptFeature( activity = activity, store = store, customTabId = customTabSessionId, fragmentManager = parentFragmentManager, loginValidationDelegate = DefaultLoginValidationDelegate( context.components.core.lazyPasswordsStorage ), isSaveLoginEnabled = { context.settings().shouldPromptToSaveLogins }, loginExceptionStorage = context.components.core.loginExceptionStorage, shareDelegate = object : ShareDelegate { override fun showShareSheet( context: Context, shareData: ShareData, onDismiss: () -> Unit, onSuccess: () -> Unit ) { val directions = NavGraphDirections.actionGlobalShareFragment( data = arrayOf(shareData), showPage = true, sessionId = getCurrentTab()?.id ) findNavController().navigate(directions) } }, onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS) }, loginPickerView = loginSelectBar, onManageLogins = { browserAnimator.captureEngineViewAndDrawStatically { val directions = NavGraphDirections.actionGlobalSavedLoginsAuthFragment() findNavController().navigate(directions) } } ), owner = this, view = view ) sessionFeature.set( feature = SessionFeature( requireComponents.core.store, requireComponents.useCases.sessionUseCases.goBack, view.engineView, customTabSessionId ), owner = this, view = view ) searchFeature.set( feature = SearchFeature(store, customTabSessionId) { request, tabId -> val parentSession = store.state.findTabOrCustomTab(tabId) val useCase = if (request.isPrivate) { requireComponents.useCases.searchUseCases.newPrivateTabSearch } else { requireComponents.useCases.searchUseCases.newTabSearch } if (parentSession is CustomTabSessionState) { useCase.invoke(request.query) requireActivity().startActivity(openInFenixIntent) } else { useCase.invoke(request.query, parentSessionId = parentSession?.id) } }, owner = this, view = view ) val accentHighContrastColor = ThemeManager.resolveAttribute(R.attr.accentHighContrast, context) sitePermissionsFeature.set( feature = SitePermissionsFeature( context = context, storage = context.components.core.permissionStorage.permissionsStorage, fragmentManager = parentFragmentManager, promptsStyling = SitePermissionsFeature.PromptsStyling( gravity = getAppropriateLayoutGravity(), shouldWidthMatchParent = true, positiveButtonBackgroundColor = accentHighContrastColor, positiveButtonTextColor = R.color.photonWhite ), sessionId = customTabSessionId, onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_APP_PERMISSIONS) }, onShouldShowRequestPermissionRationale = { shouldShowRequestPermissionRationale( it ) }, store = store ), owner = this, view = view ) sitePermissionWifiIntegration.set( feature = SitePermissionsWifiIntegration( settings = context.settings(), wifiConnectionMonitor = context.components.wifiConnectionMonitor ), owner = this, view = view ) // This component feature only works on Fenix when built on Mozilla infrastructure. if (FeatureFlags.webAuthFeature && BuildConfig.MOZILLA_OFFICIAL) { webAuthnFeature.set( feature = WebAuthnFeature( engine = requireComponents.core.engine, activity = requireActivity() ), owner = this, view = view ) } context.settings().setSitePermissionSettingListener(viewLifecycleOwner) { // If the user connects to WIFI while on the BrowserFragment, this will update the // SitePermissionsRules (specifically autoplay) accordingly runIfFragmentIsAttached { assignSitePermissionsRules() } } assignSitePermissionsRules() fullScreenFeature.set( feature = FullScreenFeature( requireComponents.core.store, requireComponents.useCases.sessionUseCases, customTabSessionId, ::viewportFitChange, ::fullScreenChanged ), owner = this, view = view ) expandToolbarOnNavigation(store) store.flowScoped(viewLifecycleOwner) { flow -> flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(customTabSessionId) } .ifChanged { tab -> tab.content.pictureInPictureEnabled } .collect { tab -> pipModeChanged(tab) } } view.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(false) if (view.swipeRefresh.isEnabled) { val primaryTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context) view.swipeRefresh.setColorSchemeColors(primaryTextColor) swipeRefreshFeature.set( feature = SwipeRefreshFeature( requireComponents.core.store, context.components.useCases.sessionUseCases.reload, view.swipeRefresh, customTabSessionId ), owner = this, view = view ) } webchannelIntegration.set( feature = FxaWebChannelFeature( requireContext(), customTabSessionId, requireComponents.core.engine, requireComponents.core.store, requireComponents.backgroundServices.accountManager, requireComponents.backgroundServices.serverConfig, setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC) ), owner = this, view = view ) initializeEngineView(toolbarHeight) } @VisibleForTesting internal fun expandToolbarOnNavigation(store: BrowserStore) { consumeFlow(store) { flow -> flow.mapNotNull { state -> state.findCustomTabOrSelectedTab(customTabSessionId) } .ifAnyChanged { tab -> arrayOf(tab.content.url, tab.content.loadRequest) } .collect { findInPageIntegration.onBackPressed() browserToolbarView.expand() } } } /** * Preserves current state of the [DynamicDownloadDialog] to persist through tab changes and * other fragments navigation. * */ private fun saveDownloadDialogState( sessionId: String?, downloadState: DownloadState, downloadJobStatus: DownloadState.Status ) { sessionId?.let { id -> sharedViewModel.downloadDialogState[id] = Pair( downloadState, downloadJobStatus == DownloadState.Status.FAILED ) } } /** * Re-initializes [DynamicDownloadDialog] if the user hasn't dismissed the dialog * before navigating away from it's original tab. * onTryAgain it will use [ContentAction.UpdateDownloadAction] to re-enqueue the former failed * download, because [DownloadsFeature] clears any queued downloads onStop. * */ @VisibleForTesting internal fun resumeDownloadDialogState( sessionId: String?, store: BrowserStore, view: View, context: Context, toolbarHeight: Int ) { val savedDownloadState = sharedViewModel.downloadDialogState[sessionId] if (savedDownloadState == null || sessionId == null) { view.viewDynamicDownloadDialog.visibility = View.GONE return } val onTryAgain: (String) -> Unit = { savedDownloadState.first?.let { dlState -> store.dispatch( ContentAction.UpdateDownloadAction( sessionId, dlState.copy(skipConfirmation = true) ) ) } } val onCannotOpenFile = { FenixSnackbar.make( view = view.browserLayout, duration = Snackbar.LENGTH_SHORT, isDisplayedWithBrowserToolbar = true ) .setText(context.getString(R.string.mozac_feature_downloads_could_not_open_file)) .show() } val onDismiss: () -> Unit = { sharedViewModel.downloadDialogState.remove(sessionId) } DynamicDownloadDialog( container = view.browserLayout, downloadState = savedDownloadState.first, metrics = requireComponents.analytics.metrics, didFail = savedDownloadState.second, tryAgain = onTryAgain, onCannotOpenFile = onCannotOpenFile, view = view.viewDynamicDownloadDialog, toolbarHeight = toolbarHeight, onDismiss = onDismiss ).show() browserToolbarView.expand() } @VisibleForTesting internal fun shouldPullToRefreshBeEnabled(inFullScreen: Boolean): Boolean { return FeatureFlags.pullToRefreshEnabled && requireContext().settings().isPullToRefreshEnabledInBrowser && !inFullScreen } @VisibleForTesting internal fun initializeEngineView(toolbarHeight: Int) { val context = requireContext() if (!context.settings().shouldUseFixedTopToolbar && context.settings().isDynamicToolbarEnabled) { getEngineView().setDynamicToolbarMaxHeight(toolbarHeight) val toolbarPosition = if (context.settings().shouldUseBottomToolbar) { MozacToolbarPosition.BOTTOM } else { MozacToolbarPosition.TOP } (getSwipeRefreshLayout().layoutParams as CoordinatorLayout.LayoutParams).behavior = EngineViewBrowserToolbarBehavior( context, null, getSwipeRefreshLayout(), toolbarHeight, toolbarPosition ) } else { // Ensure webpage's bottom elements are aligned to the very bottom of the engineView. getEngineView().setDynamicToolbarMaxHeight(0) // Effectively place the engineView on top/below of the toolbar if that is not dynamic. val swipeRefreshParams = getSwipeRefreshLayout().layoutParams as CoordinatorLayout.LayoutParams if (context.settings().shouldUseBottomToolbar) { swipeRefreshParams.bottomMargin = toolbarHeight } else { swipeRefreshParams.topMargin = toolbarHeight } } } /** * Returns a list of context menu items [ContextMenuCandidate] for the context menu */ protected abstract fun getContextMenuCandidates( context: Context, view: View ): List @CallSuper override fun onStart() { super.onStart() sitePermissionWifiIntegration.get()?.maybeAddWifiConnectedListener() } @VisibleForTesting internal fun observeRestoreComplete(store: BrowserStore, navController: NavController) { val activity = activity as HomeActivity consumeFlow(store) { flow -> flow.map { state -> state.restoreComplete } .ifChanged() .collect { restored -> if (restored) { // Once tab restoration is complete, if there are no tabs to show in the browser, go home val tabs = store.state.getNormalOrPrivateTabs( activity.browsingModeManager.mode.isPrivate ) if (tabs.isEmpty() || store.state.selectedTabId == null) { navController.popBackStack(R.id.homeFragment, false) } } } } } @VisibleForTesting internal fun observeTabSelection(store: BrowserStore) { consumeFlow(store) { flow -> flow.ifChanged { it.selectedTabId } .mapNotNull { it.selectedTab } .collect { handleTabSelected(it) } } } @VisibleForTesting @Suppress("ComplexCondition") internal fun observeTabSource(store: BrowserStore) { consumeFlow(store) { flow -> flow.mapNotNull { state -> state.selectedTab } .collect { if (!onboarding.userHasBeenOnboarded() && it.content.loadRequest?.triggeredByRedirect != true && it.source !in intentSourcesList && it.content.url !in onboardingLinksList ) { onboarding.finish() } } } } private fun handleTabSelected(selectedTab: TabSessionState) { if (!this.isRemoving) { updateThemeForSession(selectedTab) } if (browserInitialized) { view?.let { view -> fullScreenChanged(false) browserToolbarView.expand() val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) val context = requireContext() resumeDownloadDialogState(selectedTab.id, context.components.core.store, view, context, toolbarHeight) } } else { view?.let { view -> initializeUI(view) } } } @CallSuper override fun onResume() { super.onResume() val components = requireComponents val preferredColorScheme = components.core.getPreferredColorScheme() if (components.core.engine.settings.preferredColorScheme != preferredColorScheme) { components.core.engine.settings.preferredColorScheme = preferredColorScheme components.useCases.sessionUseCases.reload() } hideToolbar() components.core.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId)?.let { updateThemeForSession(it) } } @CallSuper override fun onPause() { super.onPause() if (findNavController().currentDestination?.id != R.id.searchDialogFragment) { view?.hideKeyboard() } } @CallSuper override fun onStop() { super.onStop() initUIJob?.cancel() requireComponents.core.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId) ?.let { session -> // If we didn't enter PiP, exit full screen on stop if (!session.content.pictureInPictureEnabled && fullScreenFeature.onBackPressed()) { fullScreenChanged(false) } } } @CallSuper override fun onBackPressed(): Boolean { return findInPageIntegration.onBackPressed() || fullScreenFeature.onBackPressed() || promptsFeature.onBackPressed() || sessionFeature.onBackPressed() || removeSessionIfNeeded() } override fun onBackLongPressed(): Boolean { findNavController().navigate( NavGraphDirections.actionGlobalTabHistoryDialogFragment( activeSessionId = customTabSessionId ) ) return true } /** * Saves the external app session ID to be restored later in [onViewStateRestored]. */ final override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_CUSTOM_TAB_SESSION_ID, customTabSessionId) } /** * Retrieves the external app session ID saved by [onSaveInstanceState]. */ final override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.getString(KEY_CUSTOM_TAB_SESSION_ID)?.let { if (requireComponents.core.store.state.findCustomTab(it) != null) { customTabSessionId = it } } } /** * Forwards permission grant results to one of the features. */ final override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { val feature: PermissionsFeature? = when (requestCode) { REQUEST_CODE_DOWNLOAD_PERMISSIONS -> downloadsFeature.get() REQUEST_CODE_PROMPT_PERMISSIONS -> promptsFeature.get() REQUEST_CODE_APP_PERMISSIONS -> sitePermissionsFeature.get() else -> null } feature?.onPermissionsResult(permissions, grantResults) } /** * Forwards activity results to the [ActivityResultHandler] features. */ override fun onActivityResult(requestCode: Int, data: Intent?, resultCode: Int): Boolean { return listOf( promptsFeature, webAuthnFeature ).any { it.onActivityResult(requestCode, data, resultCode) } } /** * Removes the session if it was opened by an ACTION_VIEW intent * or if it has a parent session and no more history */ protected open fun removeSessionIfNeeded(): Boolean { getCurrentTab()?.let { session -> return if (session.source == SessionState.Source.ACTION_VIEW) { activity?.finish() requireComponents.useCases.tabsUseCases.removeTab(session.id) true } else { val hasParentSession = session is TabSessionState && session.parentId != null if (hasParentSession) { requireComponents.useCases.tabsUseCases.removeTab(session.id, selectParentIfExists = true) } // We want to return to home if this session didn't have a parent session to select. val goToOverview = !hasParentSession !goToOverview } } return false } protected abstract fun navToQuickSettingsSheet( tab: SessionState, sitePermissions: SitePermissions? ) protected abstract fun navToTrackingProtectionPanel(tab: SessionState) /** * Returns the layout [android.view.Gravity] for the quick settings and ETP dialog. */ protected fun getAppropriateLayoutGravity(): Int = requireComponents.settings.toolbarPosition.androidGravity /** * Updates the site permissions rules based on user settings. */ private fun assignSitePermissionsRules() { val rules = requireComponents.settings.getSitePermissionsCustomSettingsRules() sitePermissionsFeature.withFeature { it.sitePermissionsRules = rules } } /** * Displays the quick settings dialog, * which lets the user control tracking protection and site settings. */ private fun showQuickSettingsDialog() { val tab = getCurrentTab() ?: return viewLifecycleOwner.lifecycleScope.launch(Main) { val sitePermissions: SitePermissions? = tab.content.url.toUri().host?.let { host -> val storage = requireComponents.core.permissionStorage storage.findSitePermissionsBy(host) } view?.let { navToQuickSettingsSheet(tab, sitePermissions) } } } private fun showTrackingProtectionPanel() { val tab = getCurrentTab() ?: return view?.let { navToTrackingProtectionPanel(tab) } } /** * Set the activity normal/private theme to match the current session. */ @VisibleForTesting internal fun updateThemeForSession(session: SessionState) { val sessionMode = BrowsingMode.fromBoolean(session.content.private) (activity as HomeActivity).browsingModeManager.mode = sessionMode } @VisibleForTesting internal fun getCurrentTab(): SessionState? { return requireComponents.core.store.state.findCustomTabOrSelectedTab(customTabSessionId) } private suspend fun bookmarkTapped(sessionUrl: String, sessionTitle: String) = withContext(IO) { val bookmarksStorage = requireComponents.core.bookmarksStorage val existing = bookmarksStorage.getBookmarksWithUrl(sessionUrl).firstOrNull { it.url == sessionUrl } if (existing != null) { // Bookmark exists, go to edit fragment withContext(Main) { nav( R.id.browserFragment, BrowserFragmentDirections.actionGlobalBookmarkEditFragment(existing.guid, true) ) } } else { // Save bookmark, then go to edit fragment val guid = bookmarksStorage.addItem( BookmarkRoot.Mobile.id, url = sessionUrl, title = sessionTitle, position = null ) withContext(Main) { requireComponents.analytics.metrics.track(Event.AddBookmark) view?.let { view -> FenixSnackbar.make( view = view.browserLayout, duration = FenixSnackbar.LENGTH_LONG, isDisplayedWithBrowserToolbar = true ) .setText(getString(R.string.bookmark_saved_snackbar)) .setAction(getString(R.string.edit_bookmark_snackbar_action)) { nav( R.id.browserFragment, BrowserFragmentDirections.actionGlobalBookmarkEditFragment( guid, true ) ) } .show() } } } } override fun onHomePressed() = pipFeature?.onHomePressed() ?: false /** * Exit fullscreen mode when exiting PIP mode */ private fun pipModeChanged(session: SessionState) { if (!session.content.pictureInPictureEnabled && session.content.fullScreen) { onBackPressed() fullScreenChanged(false) } } final override fun onPictureInPictureModeChanged(enabled: Boolean) { if (enabled) requireComponents.analytics.metrics.track(Event.MediaPictureInPictureState) pipFeature?.onPictureInPictureModeChanged(enabled) } private fun viewportFitChange(layoutInDisplayCutoutMode: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val layoutParams = activity?.window?.attributes layoutParams?.layoutInDisplayCutoutMode = layoutInDisplayCutoutMode activity?.window?.attributes = layoutParams } } @VisibleForTesting internal fun fullScreenChanged(inFullScreen: Boolean) { if (inFullScreen) { // Close find in page bar if opened findInPageIntegration.onBackPressed() FenixSnackbar.make( view = requireView().browserLayout, duration = Snackbar.LENGTH_SHORT, isDisplayedWithBrowserToolbar = false ) .setText(getString(R.string.full_screen_notification)) .show() activity?.enterToImmersiveMode() browserToolbarView.collapse() browserToolbarView.view.isVisible = false val browserEngine = swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams browserEngine.bottomMargin = 0 browserEngine.topMargin = 0 swipeRefresh.translationY = 0f engineView.setDynamicToolbarMaxHeight(0) // Without this, fullscreen has a margin at the top. engineView.setVerticalClipping(0) requireComponents.analytics.metrics.track(Event.MediaFullscreenState) } else { activity?.exitImmersiveModeIfNeeded() (activity as? HomeActivity)?.let { activity -> activity.themeManager.applyStatusBarTheme(activity) } if (webAppToolbarShouldBeVisible) { browserToolbarView.view.isVisible = true val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) initializeEngineView(toolbarHeight) browserToolbarView.expand() } } activity?.swipeRefresh?.isEnabled = shouldPullToRefreshBeEnabled(inFullScreen) } /* * Dereference these views when the fragment view is destroyed to prevent memory leaks */ override fun onDestroyView() { super.onDestroyView() // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( message = "onDestroyView()" ) requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this) _browserToolbarView = null _browserInteractor = null } override fun onAttach(context: Context) { super.onAttach(context) // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( message = "onAttach()" ) } override fun onDetach() { super.onDetach() // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( message = "onDetach()" ) } companion object { private const val KEY_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id" private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1 private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2 private const val REQUEST_CODE_APP_PERMISSIONS = 3 val onboardingLinksList: List = listOf( SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE), SupportUtils.getFirefoxAccountSumoUrl() ) val intentSourcesList: List = listOf( SessionState.Source.ACTION_SEARCH, SessionState.Source.ACTION_SEND, SessionState.Source.ACTION_VIEW ) } override fun onAccessibilityStateChanged(enabled: Boolean) { if (_browserToolbarView != null) { browserToolbarView.setToolbarBehavior(enabled) } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) _browserToolbarView?.dismissMenu() } // This method is called in response to native web extension messages from // content scripts (e.g the reader view extension). By the time these // messages are processed the fragment/view may no longer be attached. internal fun safeInvalidateBrowserToolbarView() { runIfFragmentIsAttached { val toolbarView = _browserToolbarView if (toolbarView != null) { toolbarView.view.invalidateActions() toolbarView.toolbarIntegration.invalidateMenu() } } } /** * Convenience method for replacing EngineView (id/engineView) in unit tests. */ @VisibleForTesting internal fun getEngineView() = engineView /** * Convenience method for replacing SwipeRefreshLayout (id/swipeRefresh) in unit tests. */ @VisibleForTesting internal fun getSwipeRefreshLayout() = swipeRefresh @VisibleForTesting internal fun shouldShowCompletedDownloadDialog( downloadState: DownloadState, status: DownloadState.Status ): Boolean { val isValidStatus = status in listOf(DownloadState.Status.COMPLETED, DownloadState.Status.FAILED) val isSameTab = downloadState.sessionId == getCurrentTab()?.id ?: false return isValidStatus && isSameTab } }