[fenix] For https://github.com/mozilla-mobile/fenix/issues/16442 - Check if fragment...
[gecko.git] / mobile / android / fenix / app / src / main / java / org / mozilla / fenix / browser / BaseBrowserFragment.kt
bloba7c90081f746fd146ed4cb674e23de44cd1fcba7
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.fenix.browser
7 import android.content.Context
8 import android.content.Intent
9 import android.os.Build
10 import android.os.Bundle
11 import android.view.Gravity
12 import android.view.LayoutInflater
13 import android.view.View
14 import android.view.ViewGroup
15 import android.view.accessibility.AccessibilityManager
16 import androidx.annotation.CallSuper
17 import androidx.annotation.VisibleForTesting
18 import androidx.coordinatorlayout.widget.CoordinatorLayout
19 import androidx.core.net.toUri
20 import androidx.core.view.isVisible
21 import androidx.fragment.app.Fragment
22 import androidx.fragment.app.activityViewModels
23 import androidx.lifecycle.lifecycleScope
24 import androidx.navigation.NavController
25 import androidx.navigation.fragment.findNavController
26 import androidx.preference.PreferenceManager
27 import com.google.android.material.snackbar.Snackbar
28 import kotlinx.android.synthetic.main.fragment_browser.*
29 import kotlinx.android.synthetic.main.fragment_browser.view.*
30 import kotlinx.coroutines.Dispatchers.IO
31 import kotlinx.coroutines.Dispatchers.Main
32 import kotlinx.coroutines.ExperimentalCoroutinesApi
33 import kotlinx.coroutines.Job
34 import kotlinx.coroutines.flow.collect
35 import kotlinx.coroutines.flow.map
36 import kotlinx.coroutines.flow.mapNotNull
37 import kotlinx.coroutines.launch
38 import kotlinx.coroutines.withContext
39 import mozilla.appservices.places.BookmarkRoot
40 import mozilla.components.browser.session.Session
41 import mozilla.components.browser.state.action.ContentAction
42 import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
43 import mozilla.components.browser.state.selector.findTab
44 import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
45 import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
46 import mozilla.components.browser.state.selector.selectedTab
47 import mozilla.components.browser.state.state.SessionState
48 import mozilla.components.browser.state.state.TabSessionState
49 import mozilla.components.browser.state.state.content.DownloadState
50 import mozilla.components.browser.state.store.BrowserStore
51 import mozilla.components.browser.thumbnails.BrowserThumbnails
52 import mozilla.components.concept.engine.prompt.ShareData
53 import mozilla.components.feature.accounts.FxaCapability
54 import mozilla.components.feature.accounts.FxaWebChannelFeature
55 import mozilla.components.feature.app.links.AppLinksFeature
56 import mozilla.components.feature.contextmenu.ContextMenuCandidate
57 import mozilla.components.feature.contextmenu.ContextMenuFeature
58 import mozilla.components.feature.downloads.DownloadsFeature
59 import mozilla.components.feature.downloads.manager.FetchDownloadManager
60 import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID
61 import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature
62 import mozilla.components.feature.privatemode.feature.SecureWindowFeature
63 import mozilla.components.feature.prompts.PromptFeature
64 import mozilla.components.feature.prompts.share.ShareDelegate
65 import mozilla.components.feature.readerview.ReaderViewFeature
66 import mozilla.components.feature.search.SearchFeature
67 import mozilla.components.feature.session.FullScreenFeature
68 import mozilla.components.feature.session.PictureInPictureFeature
69 import mozilla.components.feature.session.SessionFeature
70 import mozilla.components.feature.session.SwipeRefreshFeature
71 import mozilla.components.feature.session.behavior.EngineViewBottomBehavior
72 import mozilla.components.feature.sitepermissions.SitePermissions
73 import mozilla.components.feature.sitepermissions.SitePermissionsFeature
74 import mozilla.components.lib.state.ext.consumeFlow
75 import mozilla.components.lib.state.ext.flowScoped
76 import mozilla.components.service.sync.logins.DefaultLoginValidationDelegate
77 import mozilla.components.support.base.feature.PermissionsFeature
78 import mozilla.components.support.base.feature.UserInteractionHandler
79 import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
80 import mozilla.components.support.ktx.android.view.exitImmersiveModeIfNeeded
81 import mozilla.components.support.ktx.android.view.hideKeyboard
82 import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
83 import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
84 import org.mozilla.fenix.FeatureFlags
85 import org.mozilla.fenix.HomeActivity
86 import org.mozilla.fenix.IntentReceiverActivity
87 import org.mozilla.fenix.NavGraphDirections
88 import org.mozilla.fenix.OnBackLongPressedListener
89 import org.mozilla.fenix.addons.runIfFragmentIsAttached
90 import org.mozilla.fenix.R
91 import org.mozilla.fenix.browser.browsingmode.BrowsingMode
92 import org.mozilla.fenix.browser.readermode.DefaultReaderModeController
93 import org.mozilla.fenix.components.FenixSnackbar
94 import org.mozilla.fenix.components.FindInPageIntegration
95 import org.mozilla.fenix.components.StoreProvider
96 import org.mozilla.fenix.components.metrics.Event
97 import org.mozilla.fenix.components.toolbar.BrowserFragmentState
98 import org.mozilla.fenix.components.toolbar.BrowserFragmentStore
99 import org.mozilla.fenix.components.toolbar.BrowserInteractor
100 import org.mozilla.fenix.components.toolbar.BrowserToolbarView
101 import org.mozilla.fenix.components.toolbar.BrowserToolbarViewInteractor
102 import org.mozilla.fenix.components.toolbar.DefaultBrowserToolbarController
103 import org.mozilla.fenix.components.toolbar.DefaultBrowserToolbarMenuController
104 import org.mozilla.fenix.components.toolbar.SwipeRefreshScrollingViewBehavior
105 import org.mozilla.fenix.components.toolbar.ToolbarIntegration
106 import org.mozilla.fenix.components.toolbar.ToolbarPosition
107 import org.mozilla.fenix.downloads.DownloadService
108 import org.mozilla.fenix.downloads.DynamicDownloadDialog
109 import org.mozilla.fenix.ext.accessibilityManager
110 import org.mozilla.fenix.ext.breadcrumb
111 import org.mozilla.fenix.ext.components
112 import org.mozilla.fenix.ext.enterToImmersiveMode
113 import org.mozilla.fenix.ext.getPreferenceKey
114 import org.mozilla.fenix.ext.hideToolbar
115 import org.mozilla.fenix.ext.metrics
116 import org.mozilla.fenix.ext.nav
117 import org.mozilla.fenix.ext.requireComponents
118 import org.mozilla.fenix.ext.settings
119 import org.mozilla.fenix.home.HomeScreenViewModel
120 import org.mozilla.fenix.home.SharedViewModel
121 import org.mozilla.fenix.onboarding.FenixOnboarding
122 import org.mozilla.fenix.settings.SupportUtils
123 import org.mozilla.fenix.theme.ThemeManager
124 import org.mozilla.fenix.utils.allowUndo
125 import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration
126 import java.lang.ref.WeakReference
127 import mozilla.components.feature.media.fullscreen.MediaFullscreenOrientationFeature
128 import org.mozilla.fenix.FeatureFlags.newMediaSessionApi
131  * Base fragment extended by [BrowserFragment].
132  * This class only contains shared code focused on the main browsing content.
133  * UI code specific to the app or to custom tabs can be found in the subclasses.
134  */
135 @ExperimentalCoroutinesApi
136 @Suppress("TooManyFunctions", "LargeClass")
137 abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler,
138     OnBackLongPressedListener, AccessibilityManager.AccessibilityStateChangeListener {
140     private lateinit var browserFragmentStore: BrowserFragmentStore
141     private lateinit var browserAnimator: BrowserAnimator
143     private var _browserInteractor: BrowserToolbarViewInteractor? = null
144     protected val browserInteractor: BrowserToolbarViewInteractor
145         get() = _browserInteractor!!
147     private var _browserToolbarView: BrowserToolbarView? = null
148     @VisibleForTesting
149     internal val browserToolbarView: BrowserToolbarView
150         get() = _browserToolbarView!!
152     protected val readerViewFeature = ViewBoundFeatureWrapper<ReaderViewFeature>()
153     protected val thumbnailsFeature = ViewBoundFeatureWrapper<BrowserThumbnails>()
155     private val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
156     private val contextMenuFeature = ViewBoundFeatureWrapper<ContextMenuFeature>()
157     private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
158     private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
159     private val promptsFeature = ViewBoundFeatureWrapper<PromptFeature>()
160     private val findInPageIntegration = ViewBoundFeatureWrapper<FindInPageIntegration>()
161     private val toolbarIntegration = ViewBoundFeatureWrapper<ToolbarIntegration>()
162     private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
163     private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
164     private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
165     private val webchannelIntegration = ViewBoundFeatureWrapper<FxaWebChannelFeature>()
166     private val sitePermissionWifiIntegration =
167         ViewBoundFeatureWrapper<SitePermissionsWifiIntegration>()
168     private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
169     private var fullScreenMediaFeature =
170         ViewBoundFeatureWrapper<MediaFullscreenOrientationFeature>()
171     private var fullScreenMediaSessionFeature =
172         ViewBoundFeatureWrapper<MediaSessionFullscreenFeature>()
173     private val searchFeature = ViewBoundFeatureWrapper<SearchFeature>()
174     private var pipFeature: PictureInPictureFeature? = null
176     var customTabSessionId: String? = null
178     @VisibleForTesting
179     internal var browserInitialized: Boolean = false
180     private var initUIJob: Job? = null
181     protected var webAppToolbarShouldBeVisible = true
183     private val sharedViewModel: SharedViewModel by activityViewModels()
185     @VisibleForTesting
186     internal val onboarding by lazy { FenixOnboarding(requireContext()) }
188     @CallSuper
189     override fun onCreateView(
190         inflater: LayoutInflater,
191         container: ViewGroup?,
192         savedInstanceState: Bundle?
193     ): View {
194         customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID)
196         // Diagnostic breadcrumb for "Display already aquired" crash:
197         // https://github.com/mozilla-mobile/android-components/issues/7960
198         breadcrumb(
199             message = "onCreateView()",
200             data = mapOf(
201                 "customTabSessionId" to customTabSessionId.toString()
202             )
203         )
205         val view = inflater.inflate(R.layout.fragment_browser, container, false)
207         val activity = activity as HomeActivity
208         activity.themeManager.applyStatusBarTheme(activity)
210         browserFragmentStore = StoreProvider.get(this) {
211             BrowserFragmentStore(
212                 BrowserFragmentState()
213             )
214         }
216         return view
217     }
219     final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
220         browserInitialized = initializeUI(view) != null
222         if (customTabSessionId == null) {
223             // We currently only need this observer to navigate to home
224             // in case all tabs have been removed on startup. No need to
225             // this if we have a known session to display.
226             observeRestoreComplete(requireComponents.core.store, findNavController())
227         }
229         observeTabSelection(requireComponents.core.store)
231         lifecycleScope.launch(IO) {
232             if (!onboarding.userHasBeenOnboarded()) {
233                 observeTabSource(requireComponents.core.store)
234             }
235         }
237         requireContext().accessibilityManager.addAccessibilityStateChangeListener(this)
238     }
240     private val homeViewModel: HomeScreenViewModel by activityViewModels()
242     @Suppress("ComplexMethod", "LongMethod")
243     @CallSuper
244     @VisibleForTesting
245     internal open fun initializeUI(view: View): Session? {
246         val context = requireContext()
247         val sessionManager = context.components.core.sessionManager
248         val store = context.components.core.store
249         val activity = requireActivity() as HomeActivity
251         val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)
253         browserAnimator = BrowserAnimator(
254             fragment = WeakReference(this),
255             engineView = WeakReference(engineView),
256             swipeRefresh = WeakReference(swipeRefresh),
257             viewLifecycleScope = WeakReference(viewLifecycleOwner.lifecycleScope)
258         ).apply {
259             beginAnimateInIfNecessary()
260         }
262         return getSessionById()?.also { _ ->
263             val openInFenixIntent = Intent(context, IntentReceiverActivity::class.java).apply {
264                 action = Intent.ACTION_VIEW
265                 putExtra(HomeActivity.OPEN_TO_BROWSER, true)
266             }
268             val readerMenuController = DefaultReaderModeController(
269                 readerViewFeature,
270                 view.readerViewControlsBar,
271                 isPrivate = activity.browsingModeManager.mode.isPrivate
272             )
273             val browserToolbarController = DefaultBrowserToolbarController(
274                 store = store,
275                 activity = activity,
276                 navController = findNavController(),
277                 metrics = requireComponents.analytics.metrics,
278                 readerModeController = readerMenuController,
279                 sessionManager = requireComponents.core.sessionManager,
280                 engineView = engineView,
281                 homeViewModel = homeViewModel,
282                 customTabSession = customTabSessionId?.let { sessionManager.findSessionById(it) },
283                 onTabCounterClicked = {
284                     thumbnailsFeature.get()?.requestScreenshot()
285                     findNavController().nav(
286                         R.id.browserFragment,
287                         BrowserFragmentDirections.actionGlobalTabTrayDialogFragment()
288                     )
289                 },
290                 onCloseTab = { closedSession ->
291                     val tab = store.state.findTab(closedSession.id) ?: return@DefaultBrowserToolbarController
293                     val snackbarMessage = if (tab.content.private) {
294                         requireContext().getString(R.string.snackbar_private_tab_closed)
295                     } else {
296                         requireContext().getString(R.string.snackbar_tab_closed)
297                     }
299                     viewLifecycleOwner.lifecycleScope.allowUndo(
300                         requireView().browserLayout,
301                         snackbarMessage,
302                         requireContext().getString(R.string.snackbar_deleted_undo),
303                         {
304                             requireComponents.useCases.tabsUseCases.undo.invoke()
305                         },
306                         paddedForBottomToolbar = true,
307                         operation = { }
308                     )
309                 }
310             )
311             val browserToolbarMenuController = DefaultBrowserToolbarMenuController(
312                 activity = activity,
313                 navController = findNavController(),
314                 metrics = requireComponents.analytics.metrics,
315                 settings = context.settings(),
316                 readerModeController = readerMenuController,
317                 sessionManager = requireComponents.core.sessionManager,
318                 sessionFeature = sessionFeature,
319                 findInPageLauncher = { findInPageIntegration.withFeature { it.launch() } },
320                 swipeRefresh = swipeRefresh,
321                 browserAnimator = browserAnimator,
322                 customTabSession = customTabSessionId?.let { sessionManager.findSessionById(it) },
323                 openInFenixIntent = openInFenixIntent,
324                 bookmarkTapped = { url: String, title: String ->
325                     viewLifecycleOwner.lifecycleScope.launch {
326                         bookmarkTapped(url, title)
327                     }
328                 },
329                 scope = viewLifecycleOwner.lifecycleScope,
330                 tabCollectionStorage = requireComponents.core.tabCollectionStorage,
331                 topSitesStorage = requireComponents.core.topSitesStorage,
332                 browserStore = store
333             )
335             _browserInteractor = BrowserInteractor(
336                 browserToolbarController,
337                 browserToolbarMenuController
338             )
340             _browserToolbarView = BrowserToolbarView(
341                 container = view.browserLayout,
342                 toolbarPosition = context.settings().toolbarPosition,
343                 interactor = browserInteractor,
344                 customTabSession = customTabSessionId?.let { sessionManager.findSessionById(it) },
345                 lifecycleOwner = viewLifecycleOwner
346             )
348             toolbarIntegration.set(
349                 feature = browserToolbarView.toolbarIntegration,
350                 owner = this,
351                 view = view
352             )
354             findInPageIntegration.set(
355                 feature = FindInPageIntegration(
356                     store = store,
357                     sessionId = customTabSessionId,
358                     stub = view.stubFindInPage,
359                     engineView = view.engineView,
360                     toolbar = browserToolbarView.view
361                 ),
362                 owner = this,
363                 view = view
364             )
366             browserToolbarView.view.display.setOnSiteSecurityClickedListener {
367                 showQuickSettingsDialog()
368             }
370             browserToolbarView.view.display.setOnTrackingProtectionClickedListener {
371                 context.metrics.track(Event.TrackingProtectionIconPressed)
372                 showTrackingProtectionPanel()
373             }
375             contextMenuFeature.set(
376                 feature = ContextMenuFeature(
377                     fragmentManager = parentFragmentManager,
378                     store = store,
379                     candidates = getContextMenuCandidates(context, view.browserLayout),
380                     engineView = view.engineView,
381                     useCases = context.components.useCases.contextMenuUseCases,
382                     tabId = customTabSessionId
383                 ),
384                 owner = this,
385                 view = view
386             )
388             val allowScreenshotsInPrivateMode = context.settings().allowScreenshotsInPrivateMode
389             secureWindowFeature.set(
390                 feature = SecureWindowFeature(
391                     window = requireActivity().window,
392                     store = store,
393                     customTabId = customTabSessionId,
394                     isSecure = { !allowScreenshotsInPrivateMode && it.content.private }
395                 ),
396                 owner = this,
397                 view = view
398             )
400             if (newMediaSessionApi) {
401                 fullScreenMediaSessionFeature.set(
402                     feature = MediaSessionFullscreenFeature(
403                         requireActivity(),
404                         context.components.core.store
405                     ),
406                     owner = this,
407                     view = view
408                 )
409             } else {
410                 fullScreenMediaFeature.set(
411                     feature = MediaFullscreenOrientationFeature(
412                         requireActivity(),
413                         context.components.core.store
414                     ),
415                     owner = this,
416                     view = view
417                 )
418             }
420             val downloadFeature = DownloadsFeature(
421                 context.applicationContext,
422                 store = store,
423                 useCases = context.components.useCases.downloadUseCases,
424                 fragmentManager = childFragmentManager,
425                 tabId = customTabSessionId,
426                 downloadManager = FetchDownloadManager(
427                     context.applicationContext,
428                     store,
429                     DownloadService::class
430                 ),
431                 shouldForwardToThirdParties = {
432                     PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
433                         context.getPreferenceKey(R.string.pref_key_external_download_manager), false
434                     )
435                 },
436                 promptsStyling = DownloadsFeature.PromptsStyling(
437                     gravity = Gravity.BOTTOM,
438                     shouldWidthMatchParent = true,
439                     positiveButtonBackgroundColor = ThemeManager.resolveAttribute(
440                         R.attr.accent,
441                         context
442                     ),
443                     positiveButtonTextColor = ThemeManager.resolveAttribute(
444                         R.attr.contrastText,
445                         context
446                     ),
447                     positiveButtonRadius = (resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat()
448                 ),
449                 onNeedToRequestPermissions = { permissions ->
450                     requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS)
451                 }
452             )
454             downloadFeature.onDownloadStopped = { downloadState, _, downloadJobStatus ->
455                 // If the download is just paused, don't show any in-app notification
456                 if (downloadJobStatus == DownloadState.Status.COMPLETED ||
457                     downloadJobStatus == DownloadState.Status.FAILED
458                 ) {
460                     saveDownloadDialogState(
461                         downloadState.sessionId,
462                         downloadState,
463                         downloadJobStatus
464                     )
466                     val dynamicDownloadDialog = DynamicDownloadDialog(
467                         container = view.browserLayout,
468                         downloadState = downloadState,
469                         didFail = downloadJobStatus == DownloadState.Status.FAILED,
470                         tryAgain = downloadFeature::tryAgain,
471                         onCannotOpenFile = {
472                             FenixSnackbar.make(
473                                 view = view.browserLayout,
474                                 duration = Snackbar.LENGTH_SHORT,
475                                 isDisplayedWithBrowserToolbar = true
476                             )
477                                 .setText(context.getString(R.string.mozac_feature_downloads_could_not_open_file))
478                                 .show()
479                         },
480                         view = view.viewDynamicDownloadDialog,
481                         toolbarHeight = toolbarHeight,
482                         onDismiss = { sharedViewModel.downloadDialogState.remove(downloadState.sessionId) }
483                     )
485                     // Don't show the dialog if we aren't in the tab that started the download
486                     if (downloadState.sessionId == sessionManager.selectedSession?.id) {
487                         dynamicDownloadDialog.show()
488                         browserToolbarView.expand()
489                     }
490                 }
491             }
493             resumeDownloadDialogState(
494                 sessionManager.selectedSession?.id,
495                 store, view, context, toolbarHeight
496             )
498             downloadsFeature.set(
499                 downloadFeature,
500                 owner = this,
501                 view = view
502             )
504             pipFeature = PictureInPictureFeature(
505                 store = store,
506                 activity = requireActivity(),
507                 crashReporting = context.components.analytics.crashReporter,
508                 tabId = customTabSessionId
509             )
511             appLinksFeature.set(
512                 feature = AppLinksFeature(
513                     context,
514                     sessionManager = sessionManager,
515                     sessionId = customTabSessionId,
516                     fragmentManager = parentFragmentManager,
517                     launchInApp = { context.settings().openLinksInExternalApp },
518                     loadUrlUseCase = context.components.useCases.sessionUseCases.loadUrl
519                 ),
520                 owner = this,
521                 view = view
522             )
524             promptsFeature.set(
525                 feature = PromptFeature(
526                     fragment = this,
527                     store = store,
528                     customTabId = customTabSessionId,
529                     fragmentManager = parentFragmentManager,
530                     loginValidationDelegate = DefaultLoginValidationDelegate(
531                         context.components.core.lazyPasswordsStorage
532                     ),
533                     isSaveLoginEnabled = {
534                         context.settings().shouldPromptToSaveLogins
535                     },
536                     loginExceptionStorage = context.components.core.loginExceptionStorage,
537                     shareDelegate = object : ShareDelegate {
538                         override fun showShareSheet(
539                             context: Context,
540                             shareData: ShareData,
541                             onDismiss: () -> Unit,
542                             onSuccess: () -> Unit
543                         ) {
544                             val directions = NavGraphDirections.actionGlobalShareFragment(
545                                 data = arrayOf(shareData),
546                                 showPage = true,
547                                 sessionId = getSessionById()?.id
548                             )
549                             findNavController().navigate(directions)
550                         }
551                     },
552                     onNeedToRequestPermissions = { permissions ->
553                         requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS)
554                     },
555                     loginPickerView = loginSelectBar,
556                     onManageLogins = {
557                         browserAnimator.captureEngineViewAndDrawStatically {
558                             val directions =
559                                 NavGraphDirections.actionGlobalSavedLoginsAuthFragment()
560                             findNavController().navigate(directions)
561                         }
562                     }
563                 ),
564                 owner = this,
565                 view = view
566             )
568             sessionFeature.set(
569                 feature = SessionFeature(
570                     requireComponents.core.store,
571                     requireComponents.useCases.sessionUseCases.goBack,
572                     view.engineView,
573                     customTabSessionId
574                 ),
575                 owner = this,
576                 view = view
577             )
579             searchFeature.set(
580                 feature = SearchFeature(store, customTabSessionId) { request, tabId ->
581                     val parentSession = sessionManager.findSessionById(tabId)
582                     val useCase = if (request.isPrivate) {
583                         requireComponents.useCases.searchUseCases.newPrivateTabSearch
584                     } else {
585                         requireComponents.useCases.searchUseCases.newTabSearch
586                     }
588                     if (parentSession?.isCustomTabSession() == true) {
589                         useCase.invoke(request.query)
590                         requireActivity().startActivity(openInFenixIntent)
591                     } else {
592                         useCase.invoke(request.query, parentSessionId = parentSession?.id)
593                     }
594                 },
595                 owner = this,
596                 view = view
597             )
599             val accentHighContrastColor =
600                 ThemeManager.resolveAttribute(R.attr.accentHighContrast, context)
602             sitePermissionsFeature.set(
603                 feature = SitePermissionsFeature(
604                     context = context,
605                     storage = context.components.core.permissionStorage.permissionsStorage,
606                     fragmentManager = parentFragmentManager,
607                     promptsStyling = SitePermissionsFeature.PromptsStyling(
608                         gravity = getAppropriateLayoutGravity(),
609                         shouldWidthMatchParent = true,
610                         positiveButtonBackgroundColor = accentHighContrastColor,
611                         positiveButtonTextColor = R.color.photonWhite
612                     ),
613                     sessionId = customTabSessionId,
614                     onNeedToRequestPermissions = { permissions ->
615                         requestPermissions(permissions, REQUEST_CODE_APP_PERMISSIONS)
616                     },
617                     onShouldShowRequestPermissionRationale = {
618                         shouldShowRequestPermissionRationale(
619                             it
620                         )
621                     },
622                     store = store
623                 ),
624                 owner = this,
625                 view = view
626             )
628             sitePermissionWifiIntegration.set(
629                 feature = SitePermissionsWifiIntegration(
630                     settings = context.settings(),
631                     wifiConnectionMonitor = context.components.wifiConnectionMonitor
632                 ),
633                 owner = this,
634                 view = view
635             )
637             context.settings().setSitePermissionSettingListener(viewLifecycleOwner) {
638                 // If the user connects to WIFI while on the BrowserFragment, this will update the
639                 // SitePermissionsRules (specifically autoplay) accordingly
640                 runIfFragmentIsAttached {
641                     assignSitePermissionsRules()
642                 }
643             }
644             assignSitePermissionsRules()
646             fullScreenFeature.set(
647                 feature = FullScreenFeature(
648                     requireComponents.core.store,
649                     requireComponents.useCases.sessionUseCases,
650                     customTabSessionId,
651                     ::viewportFitChange,
652                     ::fullScreenChanged
653                 ),
654                 owner = this,
655                 view = view
656             )
658             expandToolbarOnNavigation(store)
660             store.flowScoped(viewLifecycleOwner) { flow ->
661                 flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(customTabSessionId) }
662                     .ifChanged { tab -> tab.content.pictureInPictureEnabled }
663                     .collect { tab -> pipModeChanged(tab) }
664             }
666             view.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled()
668             if (view.swipeRefresh.isEnabled) {
669                 val primaryTextColor =
670                     ThemeManager.resolveAttribute(R.attr.primaryText, context)
671                 view.swipeRefresh.setColorSchemeColors(primaryTextColor)
672                 swipeRefreshFeature.set(
673                     feature = SwipeRefreshFeature(
674                         requireComponents.core.store,
675                         context.components.useCases.sessionUseCases.reload,
676                         view.swipeRefresh,
677                         customTabSessionId
678                     ),
679                     owner = this,
680                     view = view
681                 )
682             }
684             webchannelIntegration.set(
685                 feature = FxaWebChannelFeature(
686                     requireContext(),
687                     customTabSessionId,
688                     requireComponents.core.engine,
689                     requireComponents.core.store,
690                     requireComponents.backgroundServices.accountManager,
691                     requireComponents.backgroundServices.serverConfig,
692                     setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC)
693                 ),
694                 owner = this,
695                 view = view
696             )
698             initializeEngineView(toolbarHeight)
699         }
700     }
702     @VisibleForTesting
703     internal fun expandToolbarOnNavigation(store: BrowserStore) {
704         consumeFlow(store) { flow ->
705             flow.mapNotNull {
706                 state -> state.findCustomTabOrSelectedTab(customTabSessionId)
707             }
708             .ifAnyChanged {
709                 tab -> arrayOf(tab.content.url, tab.content.loadRequest)
710             }
711             .collect {
712                 browserToolbarView.expand()
713             }
714         }
715     }
717     /**
718      * Preserves current state of the [DynamicDownloadDialog] to persist through tab changes and
719      * other fragments navigation.
720      * */
721     private fun saveDownloadDialogState(
722         sessionId: String?,
723         downloadState: DownloadState,
724         downloadJobStatus: DownloadState.Status
725     ) {
726         sessionId?.let { id ->
727             sharedViewModel.downloadDialogState[id] = Pair(
728                 downloadState,
729                 downloadJobStatus == DownloadState.Status.FAILED
730             )
731         }
732     }
734     /**
735      * Re-initializes [DynamicDownloadDialog] if the user hasn't dismissed the dialog
736      * before navigating away from it's original tab.
737      * onTryAgain it will use [ContentAction.UpdateDownloadAction] to re-enqueue the former failed
738      * download, because [DownloadsFeature] clears any queued downloads onStop.
739      * */
740     @VisibleForTesting
741     internal fun resumeDownloadDialogState(
742         sessionId: String?,
743         store: BrowserStore,
744         view: View,
745         context: Context,
746         toolbarHeight: Int
747     ) {
748         val savedDownloadState =
749             sharedViewModel.downloadDialogState[sessionId]
751         if (savedDownloadState == null || sessionId == null) {
752             view.viewDynamicDownloadDialog.visibility = View.GONE
753             return
754         }
756         val onTryAgain: (String) -> Unit = {
757             savedDownloadState.first?.let { dlState ->
758                 store.dispatch(
759                     ContentAction.UpdateDownloadAction(
760                         sessionId, dlState.copy(skipConfirmation = true)
761                     )
762                 )
763             }
764         }
766         val onCannotOpenFile = {
767             FenixSnackbar.make(
768                 view = view.browserLayout,
769                 duration = Snackbar.LENGTH_SHORT,
770                 isDisplayedWithBrowserToolbar = true
771             )
772                 .setText(context.getString(R.string.mozac_feature_downloads_could_not_open_file))
773                 .show()
774         }
776         val onDismiss: () -> Unit =
777             { sharedViewModel.downloadDialogState.remove(sessionId) }
779         DynamicDownloadDialog(
780             container = view.browserLayout,
781             downloadState = savedDownloadState.first,
782             didFail = savedDownloadState.second,
783             tryAgain = onTryAgain,
784             onCannotOpenFile = onCannotOpenFile,
785             view = view.viewDynamicDownloadDialog,
786             toolbarHeight = toolbarHeight,
787             onDismiss = onDismiss
788         ).show()
790         browserToolbarView.expand()
791     }
793     @VisibleForTesting
794     internal fun shouldPullToRefreshBeEnabled(): Boolean {
795         return FeatureFlags.pullToRefreshEnabled &&
796                 requireContext().settings().isPullToRefreshEnabledInBrowser &&
797                 !(requireActivity() as HomeActivity).isImmersive
798     }
800     private fun initializeEngineView(toolbarHeight: Int) {
801         val context = requireContext()
803         if (context.settings().isDynamicToolbarEnabled) {
804             engineView.setDynamicToolbarMaxHeight(toolbarHeight)
806             val behavior = when (context.settings().toolbarPosition) {
807                 // Set engineView dynamic vertical clipping depending on the toolbar position.
808                 ToolbarPosition.BOTTOM -> EngineViewBottomBehavior(context, null)
809                 // Set scroll flags depending on if if the browser or the website is doing the scroll.
810                 ToolbarPosition.TOP -> SwipeRefreshScrollingViewBehavior(
811                     context,
812                     null,
813                     engineView,
814                     browserToolbarView
815                 )
816             }
818             (swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams).behavior = behavior
819         } else {
820             // Ensure webpage's bottom elements are aligned to the very bottom of the engineView.
821             engineView.setDynamicToolbarMaxHeight(0)
823             // Effectively place the engineView on top of the toolbar if that is not dynamic.
824             if (context.settings().shouldUseBottomToolbar) {
825                 val browserEngine = swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams
826                 browserEngine.bottomMargin =
827                     requireContext().resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)
828             }
829         }
830     }
832     /**
833      * Returns a list of context menu items [ContextMenuCandidate] for the context menu
834      */
835     protected abstract fun getContextMenuCandidates(
836         context: Context,
837         view: View
838     ): List<ContextMenuCandidate>
840     @CallSuper
841     override fun onStart() {
842         super.onStart()
843         sitePermissionWifiIntegration.get()?.maybeAddWifiConnectedListener()
844     }
846     @VisibleForTesting
847     internal fun observeRestoreComplete(store: BrowserStore, navController: NavController) {
848         val activity = activity as HomeActivity
849         consumeFlow(store) { flow ->
850             flow.map { state -> state.restoreComplete }
851                 .ifChanged()
852                 .collect { restored ->
853                     if (restored) {
854                         // Once tab restoration is complete, if there are no tabs to show in the browser, go home
855                         val tabs =
856                             store.state.getNormalOrPrivateTabs(
857                                 activity.browsingModeManager.mode.isPrivate
858                             )
859                         if (tabs.isEmpty() || store.state.selectedTabId == null) {
860                             navController.popBackStack(R.id.homeFragment, false)
861                         }
862                     }
863                 }
864         }
865     }
867     @VisibleForTesting
868     internal fun observeTabSelection(store: BrowserStore) {
869         consumeFlow(store) { flow ->
870             flow.ifChanged {
871                 it.selectedTabId
872             }
873             .mapNotNull {
874                 it.selectedTab
875             }
876             .collect {
877                 handleTabSelected(it)
878             }
879         }
880     }
882     @VisibleForTesting
883     @Suppress("ComplexCondition")
884     internal fun observeTabSource(store: BrowserStore) {
885         consumeFlow(store) { flow ->
886             flow.mapNotNull { state ->
887                 state.selectedTab
888             }
889                 .collect {
890                 if (!onboarding.userHasBeenOnboarded() &&
891                     it.content.loadRequest?.triggeredByRedirect != true &&
892                     it.source !in intentSourcesList &&
893                     it.content.url !in onboardingLinksList
894                 ) {
895                     onboarding.finish()
896                 }
897             }
898         }
899     }
901     private fun handleTabSelected(selectedTab: TabSessionState) {
902         if (!this.isRemoving) {
903             updateThemeForSession(selectedTab)
904         }
906         if (browserInitialized) {
907             view?.let { view ->
908                 fullScreenChanged(false)
909                 browserToolbarView.expand()
911                 val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)
912                 val context = requireContext()
913                 resumeDownloadDialogState(selectedTab.id, context.components.core.store, view, context, toolbarHeight)
914             }
915         } else {
916             view?.let { view ->
917                 browserInitialized = initializeUI(view) != null
918             }
919         }
920     }
922     @CallSuper
923     override fun onResume() {
924         super.onResume()
925         val components = requireComponents
927         val preferredColorScheme = components.core.getPreferredColorScheme()
928         if (components.core.engine.settings.preferredColorScheme != preferredColorScheme) {
929             components.core.engine.settings.preferredColorScheme = preferredColorScheme
930             components.useCases.sessionUseCases.reload()
931         }
932         hideToolbar()
934         components.core.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId)?.let {
935             updateThemeForSession(it)
936         }
937     }
939     @CallSuper
940     override fun onPause() {
941         super.onPause()
942         if (findNavController().currentDestination?.id != R.id.searchDialogFragment) {
943             view?.hideKeyboard()
944         }
945     }
947     @CallSuper
948     override fun onStop() {
949         super.onStop()
950         initUIJob?.cancel()
952         requireComponents.core.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId)
953             ?.let { session ->
954                 // If we didn't enter PiP, exit full screen on stop
955                 if (!session.content.pictureInPictureEnabled && fullScreenFeature.onBackPressed()) {
956                     fullScreenChanged(false)
957                 }
958             }
959     }
961     @CallSuper
962     override fun onBackPressed(): Boolean {
963         return findInPageIntegration.onBackPressed() ||
964                 fullScreenFeature.onBackPressed() ||
965                 sessionFeature.onBackPressed() ||
966                 removeSessionIfNeeded()
967     }
969     override fun onBackLongPressed(): Boolean {
970         findNavController().navigate(
971             NavGraphDirections.actionGlobalTabHistoryDialogFragment(
972                 activeSessionId = customTabSessionId
973             )
974         )
975         return true
976     }
978     /**
979      * Saves the external app session ID to be restored later in [onViewStateRestored].
980      */
981     final override fun onSaveInstanceState(outState: Bundle) {
982         super.onSaveInstanceState(outState)
983         outState.putString(KEY_CUSTOM_TAB_SESSION_ID, customTabSessionId)
984     }
986     /**
987      * Retrieves the external app session ID saved by [onSaveInstanceState].
988      */
989     final override fun onViewStateRestored(savedInstanceState: Bundle?) {
990         super.onViewStateRestored(savedInstanceState)
991         savedInstanceState?.getString(KEY_CUSTOM_TAB_SESSION_ID)?.let {
992             if (requireComponents.core.sessionManager.findSessionById(it)?.customTabConfig != null) {
993                 customTabSessionId = it
994             }
995         }
996     }
998     /**
999      * Forwards permission grant results to one of the features.
1000      */
1001     final override fun onRequestPermissionsResult(
1002         requestCode: Int,
1003         permissions: Array<String>,
1004         grantResults: IntArray
1005     ) {
1006         val feature: PermissionsFeature? = when (requestCode) {
1007             REQUEST_CODE_DOWNLOAD_PERMISSIONS -> downloadsFeature.get()
1008             REQUEST_CODE_PROMPT_PERMISSIONS -> promptsFeature.get()
1009             REQUEST_CODE_APP_PERMISSIONS -> sitePermissionsFeature.get()
1010             else -> null
1011         }
1012         feature?.onPermissionsResult(permissions, grantResults)
1013     }
1015     /**
1016      * Forwards activity results to the prompt feature.
1017      */
1018     final override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
1019         promptsFeature.withFeature { it.onActivityResult(requestCode, resultCode, data) }
1020     }
1022     /**
1023      * Removes the session if it was opened by an ACTION_VIEW intent
1024      * or if it has a parent session and no more history
1025      */
1026     protected open fun removeSessionIfNeeded(): Boolean {
1027         getSessionById()?.let { session ->
1028             return if (session.source == SessionState.Source.ACTION_VIEW) {
1029                 activity?.finish()
1030                 requireComponents.useCases.tabsUseCases.removeTab(session)
1031                 true
1032             } else {
1033                 if (session.hasParentSession) {
1034                     // The removeTab use case does not currently select a parent session, so
1035                     // we are using sessionManager.remove
1036                     requireComponents.core.sessionManager.remove(
1037                         session,
1038                         selectParentIfExists = true
1039                     )
1040                 }
1041                 // We want to return to home if this session didn't have a parent session to select.
1042                 val goToOverview = !session.hasParentSession
1043                 !goToOverview
1044             }
1045         }
1046         return false
1047     }
1049     protected abstract fun navToQuickSettingsSheet(
1050         session: Session,
1051         sitePermissions: SitePermissions?
1052     )
1054     protected abstract fun navToTrackingProtectionPanel(session: Session)
1056     /**
1057      * Returns the layout [android.view.Gravity] for the quick settings and ETP dialog.
1058      */
1059     protected fun getAppropriateLayoutGravity(): Int =
1060         requireComponents.settings.toolbarPosition.androidGravity
1062     /**
1063      * Updates the site permissions rules based on user settings.
1064      */
1065     private fun assignSitePermissionsRules() {
1066         val rules = requireComponents.settings.getSitePermissionsCustomSettingsRules()
1068         sitePermissionsFeature.withFeature {
1069             it.sitePermissionsRules = rules
1070         }
1071     }
1073     /**
1074      * Displays the quick settings dialog,
1075      * which lets the user control tracking protection and site settings.
1076      */
1077     private fun showQuickSettingsDialog() {
1078         val session = getSessionById() ?: return
1079         viewLifecycleOwner.lifecycleScope.launch(Main) {
1080             val sitePermissions: SitePermissions? = session.url.toUri().host?.let { host ->
1081                 val storage = requireComponents.core.permissionStorage
1082                 storage.findSitePermissionsBy(host)
1083             }
1085             view?.let {
1086                 navToQuickSettingsSheet(session, sitePermissions)
1087             }
1088         }
1089     }
1091     private fun showTrackingProtectionPanel() {
1092         val session = getSessionById() ?: return
1093         view?.let {
1094             navToTrackingProtectionPanel(session)
1095         }
1096     }
1098     /**
1099      * Set the activity normal/private theme to match the current session.
1100      */
1101     @VisibleForTesting
1102     internal fun updateThemeForSession(session: SessionState) {
1103         val sessionMode = BrowsingMode.fromBoolean(session.content.private)
1104         (activity as HomeActivity).browsingModeManager.mode = sessionMode
1105     }
1107     /**
1108      * Returns the current session.
1109      */
1110     protected fun getSessionById(): Session? {
1111         val sessionManager = requireComponents.core.sessionManager
1112         val localCustomTabId = customTabSessionId
1113         return if (localCustomTabId != null) {
1114             sessionManager.findSessionById(localCustomTabId)
1115         } else {
1116             sessionManager.selectedSession
1117         }
1118     }
1120     private suspend fun bookmarkTapped(sessionUrl: String, sessionTitle: String) = withContext(IO) {
1121         val bookmarksStorage = requireComponents.core.bookmarksStorage
1122         val existing =
1123             bookmarksStorage.getBookmarksWithUrl(sessionUrl).firstOrNull { it.url == sessionUrl }
1124         if (existing != null) {
1125             // Bookmark exists, go to edit fragment
1126             withContext(Main) {
1127                 nav(
1128                     R.id.browserFragment,
1129                     BrowserFragmentDirections.actionGlobalBookmarkEditFragment(existing.guid, true)
1130                 )
1131             }
1132         } else {
1133             // Save bookmark, then go to edit fragment
1134             val guid = bookmarksStorage.addItem(
1135                 BookmarkRoot.Mobile.id,
1136                 url = sessionUrl,
1137                 title = sessionTitle,
1138                 position = null
1139             )
1141             withContext(Main) {
1142                 requireComponents.analytics.metrics.track(Event.AddBookmark)
1144                 view?.let { view ->
1145                     FenixSnackbar.make(
1146                         view = view.browserLayout,
1147                         duration = FenixSnackbar.LENGTH_LONG,
1148                         isDisplayedWithBrowserToolbar = true
1149                     )
1150                         .setText(getString(R.string.bookmark_saved_snackbar))
1151                         .setAction(getString(R.string.edit_bookmark_snackbar_action)) {
1152                             nav(
1153                                 R.id.browserFragment,
1154                                 BrowserFragmentDirections.actionGlobalBookmarkEditFragment(
1155                                     guid,
1156                                     true
1157                                 )
1158                             )
1159                         }
1160                         .show()
1161                 }
1162             }
1163         }
1164     }
1166     override fun onHomePressed() = pipFeature?.onHomePressed() ?: false
1168     /**
1169      * Exit fullscreen mode when exiting PIP mode
1170      */
1171     private fun pipModeChanged(session: SessionState) {
1172         if (!session.content.pictureInPictureEnabled && session.content.fullScreen) {
1173             onBackPressed()
1174             fullScreenChanged(false)
1175         }
1176     }
1178     final override fun onPictureInPictureModeChanged(enabled: Boolean) {
1179         pipFeature?.onPictureInPictureModeChanged(enabled)
1180     }
1182     private fun viewportFitChange(layoutInDisplayCutoutMode: Int) {
1183         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
1184             val layoutParams = activity?.window?.attributes
1185             layoutParams?.layoutInDisplayCutoutMode = layoutInDisplayCutoutMode
1186             activity?.window?.attributes = layoutParams
1187         }
1188     }
1190     @VisibleForTesting
1191     internal fun fullScreenChanged(inFullScreen: Boolean) {
1192         if (inFullScreen) {
1193             // Close find in page bar if opened
1194             findInPageIntegration.onBackPressed()
1195             FenixSnackbar.make(
1196                 view = requireView().browserLayout,
1197                 duration = Snackbar.LENGTH_SHORT,
1198                 isDisplayedWithBrowserToolbar = false
1199             )
1200                 .setText(getString(R.string.full_screen_notification))
1201                 .show()
1202             activity?.enterToImmersiveMode()
1203             browserToolbarView.view.isVisible = false
1204             val browserEngine = swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams
1205             browserEngine.bottomMargin = 0
1207             engineView.setDynamicToolbarMaxHeight(0)
1208             browserToolbarView.expand()
1209             // Without this, fullscreen has a margin at the top.
1210             engineView.setVerticalClipping(0)
1211         } else {
1212             activity?.exitImmersiveModeIfNeeded()
1213             (activity as? HomeActivity)?.let { activity ->
1214                 activity.themeManager.applyStatusBarTheme(activity)
1215             }
1216             if (webAppToolbarShouldBeVisible) {
1217                 browserToolbarView.view.isVisible = true
1218                 val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height)
1219                 initializeEngineView(toolbarHeight)
1220             }
1221         }
1222     }
1224     /*
1225      * Dereference these views when the fragment view is destroyed to prevent memory leaks
1226      */
1227     override fun onDestroyView() {
1228         super.onDestroyView()
1230         // Diagnostic breadcrumb for "Display already aquired" crash:
1231         // https://github.com/mozilla-mobile/android-components/issues/7960
1232         breadcrumb(
1233             message = "onDestroyView()"
1234         )
1236         requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this)
1237         _browserToolbarView = null
1238         _browserInteractor = null
1239     }
1241     override fun onAttach(context: Context) {
1242         super.onAttach(context)
1244         // Diagnostic breadcrumb for "Display already aquired" crash:
1245         // https://github.com/mozilla-mobile/android-components/issues/7960
1246         breadcrumb(
1247             message = "onAttach()"
1248         )
1249     }
1251     override fun onDetach() {
1252         super.onDetach()
1254         // Diagnostic breadcrumb for "Display already aquired" crash:
1255         // https://github.com/mozilla-mobile/android-components/issues/7960
1256         breadcrumb(
1257             message = "onDetach()"
1258         )
1259     }
1261     companion object {
1262         private const val KEY_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id"
1263         private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1
1264         private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2
1265         private const val REQUEST_CODE_APP_PERMISSIONS = 3
1267         val onboardingLinksList: List<String> = listOf(
1268             SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE),
1269             SupportUtils.getFirefoxAccountSumoUrl()
1270         )
1272         val intentSourcesList: List<SessionState.Source> = listOf(
1273             SessionState.Source.ACTION_SEARCH,
1274             SessionState.Source.ACTION_SEND,
1275             SessionState.Source.ACTION_VIEW
1276         )
1277     }
1279     override fun onAccessibilityStateChanged(enabled: Boolean) {
1280         if (_browserToolbarView != null) {
1281             browserToolbarView.setScrollFlags(enabled)
1282         }
1283     }