[tor-commits] [Git][tpo/applications/fenix][tor-browser-102.2.1-12.5-1] 5 commits: Bug 1812518 - Control the snackbar positioning from Fenix

Richard Pospesel (@richard) git at gitlab.torproject.org
Thu Mar 16 12:21:22 UTC 2023



Richard Pospesel pushed to branch tor-browser-102.2.1-12.5-1 at The Tor Project / Applications / fenix


Commits:
6c861589 by Mugurell at 2023-03-16T12:20:55+00:00
Bug 1812518 - Control the snackbar positioning from Fenix

Previously Android-Components - BrowserToolbarBehavior would be responsible
for positioning the snackbar above the toolbar.
With that responsibility removed we can handle in Fenix positioning the
snackbar depending on the toolbar and many more cases - like positioning it
depending on the download dialogs.

- - - - -
3dcc5001 by Mugurell at 2023-03-16T12:20:55+00:00
Bug 1812518 - Show the download dialog as an Android View

Tried to mimic the UX of a modal dialog while using Android Views.
This meant including a scrim that would consume all touches and theming the
navigation bar and status bar.
Avoiding a dialog and a separate window will allow the snackbar to see the
new "dialog" as a sibling in a CoordinatorLayout parent and so be able to
position itself based on the new "dialog".
This patch also added "start_download_dialog_layout" from A-C as it leads to
simpler and less code needed to style the layout - colors / shapes with
everything happening in XML versus calculating the values then setting them
programatically.

- - - - -
f66d9ac0 by Mugurell at 2023-03-16T12:20:55+00:00
Bug 1812518 - Show the 3rd party download dialog as an Android View

This uses the same direction as the before patch - inflating a new View that
can then serve as an anchor for the Snackbar.
Here we could use directly the AC layout as it needed no special customization.

- - - - -
39a422c9 by Mugurell at 2023-03-16T12:20:55+00:00
Bug 1812518 - Fix UI tests affected by the refactoring.

- - - - -
10a73978 by Mugurell at 2023-03-16T12:20:55+00:00
Bug 1812518 - Update to latest AndroidComponents

The new version will contain the support for allowing to style download dialogs.

- - - - -


20 changed files:

- app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt
- app/src/androidTest/java/org/mozilla/fenix/ui/DownloadFileTypesTest.kt
- app/src/androidTest/java/org/mozilla/fenix/ui/DownloadTest.kt
- app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
- app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
- app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt
- app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt
- + app/src/main/java/org/mozilla/fenix/components/FenixSnackbarBehavior.kt
- + app/src/main/java/org/mozilla/fenix/downloads/StartDownloadDialog.kt
- + app/src/main/res/drawable/download_dialog_download_button_background.xml
- + app/src/main/res/layout/dialog_scrim.xml
- app/src/main/res/layout/fragment_browser.xml
- + app/src/main/res/layout/start_download_dialog_layout.xml
- app/src/main/res/values/colors.xml
- app/src/main/res/values/dimens.xml
- + app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt
- + app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt
- + app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt
- + app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt
- app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt


Changes:

=====================================
app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt
=====================================
@@ -57,8 +57,10 @@ class CollectionTest {
         featureSettingsHelper.resetAllFeatureFlags()
     }
 
-    @Test
+
     // open a webpage, and add currently opened tab to existing collection
+    @Ignore("Failing, see: https://bugzilla.mozilla.org/show_bug.cgi?id=1812580")
+    @Test
     fun mainMenuSaveToExistingCollection() {
         val firstWebPage = getGenericAsset(mockWebServer, 1)
         val secondWebPage = getGenericAsset(mockWebServer, 2)
@@ -84,6 +86,7 @@ class CollectionTest {
         }
     }
 
+    @Ignore("Failing, see: https://bugzilla.mozilla.org/show_bug.cgi?id=1812580")
     @Test
     fun verifyAddTabButtonOfCollectionMenu() {
         val firstWebPage = getGenericAsset(mockWebServer, 1)


=====================================
app/src/androidTest/java/org/mozilla/fenix/ui/DownloadFileTypesTest.kt
=====================================
@@ -75,7 +75,7 @@ class DownloadFileTypesTest(fileName: String) {
             verifyDownloadPrompt(downloadFile)
         }.clickDownload {
             verifyDownloadNotificationPopup()
-        }.closePrompt {
+        }.closeCompletedDownloadPrompt {
         }.openThreeDotMenu {
         }.openDownloadsManager {
             waitForDownloadsListToExist()


=====================================
app/src/androidTest/java/org/mozilla/fenix/ui/DownloadTest.kt
=====================================
@@ -105,7 +105,7 @@ class DownloadTest {
             verifyDownloadPrompt(downloadFile)
         }.clickDownload {
             verifyDownloadNotificationPopup()
-        }.closePrompt { }
+        }
         mDevice.openNotification()
         notificationShade {
             verifySystemNotificationExists("Download completed")


=====================================
app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt
=====================================
@@ -12,7 +12,6 @@ import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.assertion.ViewAssertions.matches
 import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.intent.matcher.IntentMatchers
-import androidx.test.espresso.matcher.RootMatchers.isDialog
 import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
 import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
 import androidx.test.espresso.matcher.ViewMatchers.withId
@@ -82,6 +81,13 @@ class DownloadRobot {
             return Transition()
         }
 
+        fun closeCompletedDownloadPrompt(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
+            closeCompletedDownloadButton().click()
+
+            BrowserRobot().interact()
+            return BrowserRobot.Transition()
+        }
+
         fun closePrompt(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
             closePromptButton().click()
 
@@ -177,12 +183,14 @@ private fun assertDownloadNotificationPopup() {
     )
 }
 
+private fun closeCompletedDownloadButton() =
+    onView(withId(R.id.download_dialog_close_button))
+
 private fun closePromptButton() =
-    onView(withContentDescription("Close"))
+    onView(withId(R.id.close_button))
 
 private fun downloadButton() =
     onView(withText("Download"))
-        .inRoot(isDialog())
         .check(matches(isDisplayed()))
 
 private fun openDownloadButton() =


=====================================
app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt
=====================================
@@ -108,6 +108,9 @@ 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.downloads.FirstPartyDownloadDialog
+import org.mozilla.fenix.downloads.StartDownloadDialog
+import org.mozilla.fenix.downloads.ThirdPartyDownloadDialog
 import org.mozilla.fenix.ext.accessibilityManager
 import org.mozilla.fenix.ext.breadcrumb
 import org.mozilla.fenix.ext.components
@@ -213,6 +216,8 @@ abstract class BaseBrowserFragment :
     @VisibleForTesting
     internal val onboarding by lazy { FenixOnboarding(requireContext()) }
 
+    private var currentStartDownloadDialog: StartDownloadDialog? = null
+
     @CallSuper
     override fun onCreateView(
         inflater: LayoutInflater,
@@ -345,7 +350,7 @@ abstract class BaseBrowserFragment :
                 }
 
                 viewLifecycleOwner.lifecycleScope.allowUndo(
-                    binding.browserLayout,
+                    binding.dynamicSnackbarContainer,
                     snackbarMessage,
                     requireContext().getString(R.string.snackbar_deleted_undo),
                     {
@@ -424,7 +429,7 @@ abstract class BaseBrowserFragment :
             feature = ContextMenuFeature(
                 fragmentManager = parentFragmentManager,
                 store = store,
-                candidates = getContextMenuCandidates(context, binding.browserLayout),
+                candidates = getContextMenuCandidates(context, binding.dynamicSnackbarContainer),
                 engineView = binding.engineView,
                 useCases = context.components.useCases.contextMenuUseCases,
                 tabId = customTabSessionId
@@ -493,7 +498,32 @@ abstract class BaseBrowserFragment :
             ),
             onNeedToRequestPermissions = { permissions ->
                 requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS)
-            }
+            },
+            customFirstPartyDownloadDialog = { filename, contentSize, positiveAction, negativeAction ->
+                FirstPartyDownloadDialog(
+                    activity = requireActivity(),
+                    filename = filename.value,
+                    contentSize = contentSize.value,
+                    positiveButtonAction = positiveAction.value,
+                    negativeButtonAction = negativeAction.value,
+                ).onDismiss {
+                    currentStartDownloadDialog = null
+                }.show(binding.startDownloadDialogContainer).also {
+                    currentStartDownloadDialog = it
+                }
+            },
+            customThirdPartyDownloadDialog = { downloaderApps, onAppSelected, negativeActionCallback ->
+                ThirdPartyDownloadDialog(
+                    activity = requireActivity(),
+                    downloaderApps = downloaderApps.value,
+                    onAppSelected = onAppSelected.value,
+                    negativeButtonAction = negativeActionCallback.value,
+                ).onDismiss {
+                    currentStartDownloadDialog = null
+                }.show(binding.startDownloadDialogContainer).also {
+                    currentStartDownloadDialog = it
+                }
+            },
         )
 
         downloadFeature.onDownloadStopped = { downloadState, _, downloadJobStatus ->
@@ -512,7 +542,7 @@ abstract class BaseBrowserFragment :
                     didFail = downloadJobStatus == DownloadState.Status.FAILED,
                     tryAgain = downloadFeature::tryAgain,
                     onCannotOpenFile = {
-                        showCannotOpenFileError(binding.browserLayout, context, it)
+                        showCannotOpenFileError(binding.dynamicSnackbarContainer, context, it)
                     },
                     binding = binding.viewDynamicDownloadDialog,
                     toolbarHeight = toolbarHeight
@@ -945,7 +975,7 @@ abstract class BaseBrowserFragment :
             didFail = savedDownloadState.second,
             tryAgain = onTryAgain,
             onCannotOpenFile = {
-                showCannotOpenFileError(binding.browserLayout, context, it)
+                showCannotOpenFileError(binding.dynamicSnackbarContainer, context, it)
             },
             binding = binding.viewDynamicDownloadDialog,
             toolbarHeight = toolbarHeight,
@@ -1036,6 +1066,7 @@ abstract class BaseBrowserFragment :
                     it.selectedTab
                 }
                 .collect {
+                    currentStartDownloadDialog?.dismiss()
                     handleTabSelected(it)
                 }
         }
@@ -1104,6 +1135,7 @@ abstract class BaseBrowserFragment :
     override fun onStop() {
         super.onStop()
         initUIJob?.cancel()
+        currentStartDownloadDialog?.dismiss()
 
         requireComponents.core.store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId)
             ?.let { session ->
@@ -1119,6 +1151,10 @@ abstract class BaseBrowserFragment :
         return findInPageIntegration.onBackPressed() ||
             fullScreenFeature.onBackPressed() ||
             promptsFeature.onBackPressed() ||
+            currentStartDownloadDialog?.let {
+                it.dismiss()
+                true
+            } ?: false ||
             sessionFeature.onBackPressed() ||
             removeSessionIfNeeded()
     }
@@ -1281,7 +1317,7 @@ abstract class BaseBrowserFragment :
                 withContext(Main) {
                     view?.let {
                         FenixSnackbar.make(
-                            view = binding.browserLayout,
+                            view = binding.dynamicSnackbarContainer,
                             duration = FenixSnackbar.LENGTH_LONG,
                             isDisplayedWithBrowserToolbar = true
                         )
@@ -1303,7 +1339,7 @@ abstract class BaseBrowserFragment :
 
                     view?.let {
                         FenixSnackbar.make(
-                            view = binding.browserLayout,
+                            view = binding.dynamicSnackbarContainer,
                             duration = FenixSnackbar.LENGTH_LONG,
                             isDisplayedWithBrowserToolbar = true
                         )
@@ -1346,7 +1382,7 @@ abstract class BaseBrowserFragment :
             // Close find in page bar if opened
             findInPageIntegration.onBackPressed()
             FenixSnackbar.make(
-                view = binding.browserLayout,
+                view = binding.dynamicSnackbarContainer,
                 duration = Snackbar.LENGTH_SHORT,
                 isDisplayedWithBrowserToolbar = false
             )
@@ -1420,12 +1456,12 @@ abstract class BaseBrowserFragment :
     }
 
     private fun showCannotOpenFileError(
-        view: View,
+        container: ViewGroup,
         context: Context,
         downloadState: DownloadState
     ) {
         FenixSnackbar.make(
-            view = view,
+            view = container,
             duration = Snackbar.LENGTH_SHORT,
             isDisplayedWithBrowserToolbar = true
         ).setText(DynamicDownloadDialog.getCannotOpenFileErrorMessage(context, downloadState))


=====================================
app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt
=====================================
@@ -347,7 +347,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
                     }
                 }
                 FenixSnackbar.make(
-                    view = binding.browserLayout,
+                    view = binding.dynamicSnackbarContainer,
                     duration = Snackbar.LENGTH_SHORT,
                     isDisplayedWithBrowserToolbar = true
                 )


=====================================
app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt
=====================================
@@ -141,10 +141,20 @@ class FenixSnackbar private constructor(
                         0
                     }
                 )
+
+                if (parent.id == R.id.dynamicSnackbarContainer) {
+                    (parent.layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
+                        behavior = FenixSnackbarBehavior<FrameLayout>(
+                            context = view.context,
+                            toolbarPosition = view.context.settings().toolbarPosition,
+                        )
+                    }
+                }
             }
         }
 
         // Use the same implementation of `Snackbar`
+        @Suppress("ReturnCount")
         private fun findSuitableParent(_view: View?): ViewGroup? {
             var view = _view
             var fallback: ViewGroup? = null
@@ -159,6 +169,10 @@ class FenixSnackbar private constructor(
                         return view
                     }
 
+                    if (view.id == R.id.dynamicSnackbarContainer) {
+                        return view
+                    }
+
                     fallback = view
                 }
 


=====================================
app/src/main/java/org/mozilla/fenix/components/FenixSnackbarBehavior.kt
=====================================
@@ -0,0 +1,77 @@
+/* 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.components
+
+import android.content.Context
+import android.view.Gravity
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+
+/**
+ * [CoordinatorLayout.Behavior] to be used by a snackbar that want to ensure it it always positioned
+ * such that it will be shown on top (vertically) of other siblings that may obstruct it's view.
+ *
+ * @param context [Context] used for various system interactions.
+ * @param toolbarPosition Where the toolbar is positioned on the screen.
+ * Depending on it's position (top / bottom) the snackbar will be shown below / above the toolbar.
+ */
+class FenixSnackbarBehavior<V : View>(
+    context: Context,
+    @get:VisibleForTesting internal val toolbarPosition: ToolbarPosition,
+) : CoordinatorLayout.Behavior<V>(context, null) {
+
+    private val dependenciesIds = listOf(
+        R.id.startDownloadDialogContainer,
+        R.id.viewDynamicDownloadDialog,
+        R.id.toolbar,
+    )
+
+    private var currentAnchorId: Int? = View.NO_ID
+
+    override fun layoutDependsOn(
+        parent: CoordinatorLayout,
+        child: V,
+        dependency: View,
+    ): Boolean {
+        val anchorId = dependenciesIds
+            .intersect(parent.children.filter { it.isVisible }.map { it.id }.toSet())
+            .firstOrNull()
+
+        // It is possible that previous anchor's visibility is changed.
+        // The layout is updated and layoutDependsOn is called but onDependentViewChanged not.
+        // We have to check here if a new anchor is available and reparent the snackbar.
+        // This check also ensures we are not positioning the snackbar multiple times for the same anchor.
+        return if (anchorId != currentAnchorId) {
+            positionSnackbar(child, parent.children.firstOrNull { it.id == anchorId })
+            true
+        } else {
+            false
+        }
+    }
+
+    private fun positionSnackbar(snackbar: View, dependency: View?) {
+        currentAnchorId = dependency?.id ?: View.NO_ID
+        val params = snackbar.layoutParams as CoordinatorLayout.LayoutParams
+
+        if (dependency == null || (dependency.id == R.id.toolbar && toolbarPosition == ToolbarPosition.TOP)) {
+            // Position the snackbar at the bottom of the screen.
+            params.anchorId = View.NO_ID
+            params.anchorGravity = Gravity.NO_GRAVITY
+            params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+        } else {
+            // Position the snackbar just above the anchor.
+            params.anchorId = dependency.id
+            params.anchorGravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
+            params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
+        }
+
+        snackbar.layoutParams = params
+    }
+}


=====================================
app/src/main/java/org/mozilla/fenix/downloads/StartDownloadDialog.kt
=====================================
@@ -0,0 +1,248 @@
+/* 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.downloads
+
+import android.app.Activity
+import android.app.Dialog
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver.OnGlobalLayoutListener
+import android.view.Window
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.annotation.VisibleForTesting
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.children
+import androidx.viewbinding.ViewBinding
+import mozilla.components.feature.downloads.databinding.MozacDownloaderChooserPromptBinding
+import mozilla.components.feature.downloads.toMegabyteOrKilobyteString
+import mozilla.components.feature.downloads.ui.DownloaderApp
+import mozilla.components.feature.downloads.ui.DownloaderAppAdapter
+import mozilla.components.support.ktx.android.view.setNavigationBarTheme
+import mozilla.components.support.ktx.android.view.setStatusBarTheme
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.DialogScrimBinding
+import org.mozilla.fenix.databinding.StartDownloadDialogLayoutBinding
+import org.mozilla.fenix.ext.settings
+
+/**
+ * Parent of all download views that can mimic a modal [Dialog].
+ *
+ * @param activity The [Activity] in which the dialog will be shown.
+ * Used to update the activity [Window] to best mimic a modal dialog.
+ */
+abstract class StartDownloadDialog(
+    private val activity: Activity,
+) {
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    internal var binding: ViewBinding? = null
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    internal var container: ViewGroup? = null
+    private var scrim: DialogScrimBinding? = null
+
+    @VisibleForTesting
+    internal var onDismiss: () -> Unit = {}
+
+    @VisibleForTesting
+    internal var initialNavigationBarColor = activity.window.navigationBarColor
+
+    @VisibleForTesting
+    internal var initialStatusBarColor = activity.window.statusBarColor
+
+    /**
+     * Show the download view.
+     *
+     * @param container The [ViewGroup] in which the download view will be inflated.
+     */
+    fun show(container: ViewGroup): StartDownloadDialog {
+        this.container = container
+
+        val dialogParent = container.parent as? ViewGroup
+        dialogParent?.let {
+            scrim = DialogScrimBinding.inflate(LayoutInflater.from(activity), dialogParent, true).apply {
+                this.scrim.setOnClickListener {
+                    // Empty listener needed to prevent clicking through.
+                }
+            }
+        }
+
+        setupView()
+
+        if (activity.settings().accessibilityServicesEnabled) {
+            disableSiblingsAccessibility(dialogParent)
+        }
+
+        container.apply {
+            val params = layoutParams as CoordinatorLayout.LayoutParams
+            params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+            layoutParams = params
+
+            // Set a higher elevation than the toolbar sibling which we should cover.
+            elevation = activity.resources.getDimension(R.dimen.browser_fragment_download_dialog_elevation)
+            visibility = View.VISIBLE
+        }
+
+        activity.window.setNavigationBarTheme(ContextCompat.getColor(activity, R.color.material_scrim_color))
+        activity.window.setStatusBarTheme(ContextCompat.getColor(activity, R.color.material_scrim_color))
+
+        return this
+    }
+
+    /**
+     * Set a callback for when the download view is dismissed.
+     *
+     * @param callback The callback for when the view is dismissed.
+     */
+    fun onDismiss(callback: () -> Unit): StartDownloadDialog {
+        this.onDismiss = callback
+        return this
+    }
+
+    /**
+     * Immediately dismiss the current download view if it is shown.
+     * This will restore the previous UI removing any other layout / window customizations.
+     */
+    fun dismiss() {
+        scrim?.let {
+            (it.root.parent as? ViewGroup)?.removeView(it.root)
+        }
+        binding?.let {
+            (it.root.parent as? ViewGroup)?.removeView(it.root)
+        }
+        enableSiblingsAccessibility(container?.parent as? ViewGroup)
+
+        container?.visibility = View.GONE
+
+        activity.window.setNavigationBarTheme(initialNavigationBarColor)
+        activity.window.setStatusBarTheme(initialStatusBarColor)
+
+        onDismiss()
+    }
+
+    @VisibleForTesting
+    internal fun enableSiblingsAccessibility(parent: ViewGroup?) {
+        parent?.children
+            ?.filterNot { it.id == R.id.startDownloadDialogContainer }
+            ?.forEach {
+                ViewCompat.setImportantForAccessibility(
+                    it,
+                    ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES,
+                )
+            }
+    }
+
+    @VisibleForTesting
+    internal fun disableSiblingsAccessibility(parent: ViewGroup?) {
+        parent?.children
+            ?.filterNot { it.id == R.id.startDownloadDialogContainer }
+            ?.forEach {
+                ViewCompat.setImportantForAccessibility(
+                    it,
+                    ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
+                )
+            }
+    }
+
+    /**
+     * Bind all download data to the download view.
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    internal abstract fun setupView()
+}
+
+/**
+ * A download view mimicking a modal dialog that allows the user to download a file with the current application.
+ *
+ * @param activity The [Activity] in which the dialog will be shown.
+ * Used to update the activity [Window] to best mimic a modal dialog.
+ * @param filename Name of the file to be downloaded. It wil be shown without any modification.
+ * @param contentSize Size of the file to be downloaded expressed as a number of bytes.
+ * It will automatically be parsed to the appropriate kilobyte or megabyte value before being shown.
+ * @param positiveButtonAction Callback for when the user interacts with the dialog to start the download.
+ * @param negativeButtonAction Callback for when the user interacts with the dialog to dismiss it.
+ */
+class FirstPartyDownloadDialog(
+    private val activity: Activity,
+    private val filename: String,
+    private val contentSize: Long,
+    private val positiveButtonAction: () -> Unit,
+    private val negativeButtonAction: () -> Unit,
+) : StartDownloadDialog(activity) {
+    override fun setupView() {
+        val dialog = StartDownloadDialogLayoutBinding.inflate(LayoutInflater.from(activity), container, true)
+            .also { binding = it }
+
+        if (contentSize > 0L) {
+            val contentSize = contentSize.toMegabyteOrKilobyteString()
+            dialog.title.text =
+                activity.getString(R.string.mozac_feature_downloads_dialog_title2, contentSize)
+        }
+
+        dialog.filename.text = filename
+
+        dialog.downloadButton.setOnClickListener {
+            positiveButtonAction()
+            dismiss()
+        }
+
+        dialog.closeButton.setOnClickListener {
+            negativeButtonAction()
+            dismiss()
+        }
+
+        if (activity.settings().accessibilityServicesEnabled) {
+            // Ensure the title of the dialog is focused and read by talkback first.
+            dialog.root.viewTreeObserver.addOnGlobalLayoutListener(
+                object : OnGlobalLayoutListener {
+                    override fun onGlobalLayout() {
+                        dialog.root.viewTreeObserver.removeOnGlobalLayoutListener(this)
+                        dialog.title.run {
+                            sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED)
+                            performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null)
+                        }
+                    }
+                },
+            )
+        }
+    }
+}
+
+/**
+ * A download view mimicking a modal dialog that presents the user with a list of all apps
+ * that can handle the download request.
+ *
+ * @param activity The [Activity] in which the dialog will be shown.
+ * Used to update the activity [Window] to best mimic a modal dialog.
+ * @param downloaderApps List of all applications that can handle the download request.
+ * @param onAppSelected Callback for when the user chooses a specific application to handle the download request.
+ * @param negativeButtonAction Callback for when the user interacts with the dialog to dismiss it.
+ */
+class ThirdPartyDownloadDialog(
+    private val activity: Activity,
+    private val downloaderApps: List<DownloaderApp>,
+    private val onAppSelected: (DownloaderApp) -> Unit,
+    private val negativeButtonAction: () -> Unit,
+) : StartDownloadDialog(activity) {
+    override fun setupView() {
+        val dialog = MozacDownloaderChooserPromptBinding.inflate(LayoutInflater.from(activity), container, true)
+            .also { binding = it }
+
+        val recyclerView = dialog.appsList
+        recyclerView.adapter = DownloaderAppAdapter(activity, downloaderApps) { app ->
+            onAppSelected(app)
+            dismiss()
+        }
+
+        dialog.closeButton.setOnClickListener {
+            negativeButtonAction()
+            dismiss()
+        }
+    }
+}


=====================================
app/src/main/res/drawable/download_dialog_download_button_background.xml
=====================================
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="@dimen/bottom_sheet_corner_radius"/>
+    <solid android:color="?attr/accent" />
+</shape>


=====================================
app/src/main/res/layout/dialog_scrim.xml
=====================================
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/scrim"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/material_scrim_color"
+    android:clipToPadding="false"
+    android:fitsSystemWindows="true"
+    android:importantForAccessibility="no"
+    android:soundEffectsEnabled="false" />


=====================================
app/src/main/res/layout/fragment_browser.xml
=====================================
@@ -66,6 +66,20 @@
                 android:layout_height="match_parent"
                 android:visibility="gone" />
 
+            <FrameLayout
+                android:id="@+id/startDownloadDialogContainer"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_gravity="bottom"
+                android:visibility="gone"
+                android:elevation="@dimen/browser_fragment_toolbar_elevation"/>
+
+            <FrameLayout
+                android:id="@+id/dynamicSnackbarContainer"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:elevation="@dimen/browser_fragment_toolbar_elevation"/>
+
         </androidx.coordinatorlayout.widget.CoordinatorLayout>
 
         <mozilla.components.feature.prompts.creditcard.CreditCardSelectBar


=====================================
app/src/main/res/layout/start_download_dialog_layout.xml
=====================================
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/dialogLayout"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?android:windowBackground"
+    android:orientation="vertical"
+    app:layout_constraintBottom_toBottomOf="parent">
+
+    <androidx.appcompat.widget.AppCompatImageView
+        android:id="@+id/icon"
+        android:layout_width="32dp"
+        android:layout_height="32dp"
+        android:layout_alignParentTop="true"
+        android:layout_marginStart="16dp"
+        android:layout_marginTop="16dp"
+        android:importantForAccessibility="no"
+        android:scaleType="center"
+        app:srcCompat="@drawable/mozac_feature_download_ic_download"
+        app:tint="?android:attr/textColorPrimary" />
+
+    <TextView
+        android:id="@+id/title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignBaseline="@id/icon"
+        android:layout_alignParentTop="true"
+        android:layout_marginStart="3dp"
+        android:layout_marginTop="16dp"
+        android:layout_marginEnd="11dp"
+        android:layout_toStartOf="@id/close_button"
+        android:layout_toEndOf="@id/icon"
+        android:paddingStart="5dp"
+        android:paddingTop="4dp"
+        android:paddingEnd="5dp"
+        android:text="@string/mozac_feature_downloads_dialog_download"
+        android:textColor="?android:attr/textColorPrimary"
+        tools:text="Download (85.7 MB)"
+        tools:textColor="#000000" />
+
+    <androidx.appcompat.widget.AppCompatImageButton
+        android:id="@+id/close_button"
+        android:layout_width="48dp"
+        android:layout_height="48dp"
+        android:layout_alignBaseline="@id/icon"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentEnd="true"
+        android:layout_marginStart="3dp"
+        android:scaleType="centerInside"
+        android:background="@null"
+        android:contentDescription="@string/mozac_feature_downloads_button_close"
+        app:srcCompat="@drawable/mozac_ic_close"
+        app:tint="?android:attr/textColorPrimary"
+        tools:textColor="#000000" />
+
+    <TextView
+        android:id="@+id/filename"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_below="@id/title"
+        android:layout_alignBaseline="@id/icon"
+        android:layout_marginStart="3dp"
+        android:layout_marginTop="16dp"
+        android:layout_toEndOf="@id/icon"
+        android:paddingStart="5dp"
+        android:paddingTop="4dp"
+        android:paddingEnd="5dp"
+        android:textColor="?android:attr/textColorPrimary"
+        tools:text="@tools:sample/lorem/random"
+        tools:textColor="#000000" />
+
+    <Button
+        android:id="@+id/download_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_below="@id/filename"
+        android:layout_alignParentEnd="true"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="16dp"
+        android:layout_marginEnd="16dp"
+        android:layout_marginBottom="16dp"
+        android:paddingStart="8dp"
+        android:paddingEnd="8dp"
+        android:text="@string/mozac_feature_downloads_dialog_download"
+        android:background="@drawable/download_dialog_download_button_background"
+        android:textColor="?attr/textOnColorPrimary"
+        android:textAllCaps="false"
+        tools:ignore="ButtonStyleXmlDetector" />
+</RelativeLayout>


=====================================
app/src/main/res/values/colors.xml
=====================================
@@ -337,4 +337,7 @@
 
     <!-- App Spinners colors -->
     <color name="spinner_selected_item">#1415141A</color>
+
+    <!-- Material Design colors -->
+    <color name="material_scrim_color">#52000000</color>
 </resources>


=====================================
app/src/main/res/values/dimens.xml
=====================================
@@ -82,6 +82,8 @@
     <!--The size of the gap between the tab preview and content layout.-->
     <dimen name="browser_fragment_gesture_preview_offset">48dp</dimen>
     <dimen name="browser_fragment_toolbar_elevation">16dp</dimen>
+    <!-- The download dialogs are shown above the toolbar so they need a bigger elevation. -->
+    <dimen name="browser_fragment_download_dialog_elevation">17dp</dimen>
 
     <!-- Search Fragment -->
     <dimen name="search_fragment_clipboard_item_height">56dp</dimen>


=====================================
app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt
=====================================
@@ -0,0 +1,251 @@
+/* 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.components
+
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+ at RunWith(FenixRobolectricTestRunner::class)
+class FenixSnackbarBehaviorTest {
+    private val snackbarParams = CoordinatorLayout.LayoutParams(0, 0)
+    private val snackbarContainer = FrameLayout(testContext)
+    private val dependency = View(testContext)
+    private val parent = CoordinatorLayout(testContext)
+
+    @Before
+    fun setup() {
+        snackbarContainer.layoutParams = snackbarParams
+        parent.addView(dependency)
+    }
+
+    @Test
+    fun `GIVEN no valid anchors are shown WHEN the snackbar is shown THEN don't anchor it`() {
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+    }
+
+    @Test
+    fun `GIVEN the dynamic download dialog is shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
+        dependency.id = R.id.viewDynamicDownloadDialog
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+        assertSnackbarPlacementAboveAnchor()
+    }
+
+    @Test
+    fun `GIVEN a bottom toolbar is shown WHEN the snackbar is shown THEN place the snackbar above the toolbar`() {
+        dependency.id = R.id.toolbar
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+        assertSnackbarPlacementAboveAnchor()
+    }
+
+    @Test
+    fun `GIVEN a toolbar and a dynamic download dialog are shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
+        listOf(R.id.viewDynamicDownloadDialog, R.id.toolbar).forEach {
+            parent.addView(View(testContext).apply { id = it })
+        }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+        assertSnackbarPlacementAboveAnchor(parent.findViewById(R.id.viewDynamicDownloadDialog))
+    }
+
+    @Test
+    fun `GIVEN a toolbar, a download dialog and a dynamic download dialog are shown WHEN the snackbar is shown THEN place the snackbar above the download dialog`() {
+        listOf(R.id.viewDynamicDownloadDialog, R.id.toolbar, R.id.startDownloadDialogContainer).forEach {
+            parent.addView(View(testContext).apply { id = it })
+        }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+        assertSnackbarPlacementAboveAnchor(parent.findViewById(R.id.startDownloadDialogContainer))
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored to the dynamic download dialog and a bottom toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar above the toolbar`() {
+        val dialog = View(testContext)
+            .apply { id = R.id.viewDynamicDownloadDialog }
+            .also { parent.addView(it) }
+        val toolbar = View(testContext)
+            .apply { id = R.id.toolbar }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        // Test the scenario where the dialog is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        dialog.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+
+        // Test the scenario where the dialog is removed from parent.
+        dialog.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        parent.removeView(dialog)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored to a download dialog and another dynamic dialog is shown WHEN the dialog is not shown anymore THEN place the snackbar above the dynamic dialog`() {
+        val dialog = View(testContext)
+            .apply { id = R.id.startDownloadDialogContainer }
+            .also { parent.addView(it) }
+        val dynamicDialog = View(testContext)
+            .apply { id = R.id.viewDynamicDownloadDialog }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        // Test the scenario where the dialog is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        dialog.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dynamicDialog)
+
+        // Test the scenario where the dialog is removed from parent.
+        dialog.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        parent.removeView(dialog)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dynamicDialog)
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored to a download dialog and a bottom toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar above the toolbar`() {
+        val dialog = View(testContext)
+            .apply { id = R.id.startDownloadDialogContainer }
+            .also { parent.addView(it) }
+        val toolbar = View(testContext)
+            .apply { id = R.id.toolbar }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        // Test the scenario where the dialog is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        dialog.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+
+        // Test the scenario where the dialog is removed from parent.
+        dialog.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        parent.removeView(dialog)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored to the bottom toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
+        val toolbar = View(testContext)
+            .apply { id = R.id.toolbar }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+        // Test the scenario where the toolbar is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+        toolbar.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+        // Test the scenario where the toolbar is removed from parent.
+        toolbar.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(toolbar)
+        parent.removeView(toolbar)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored to the dynamic download dialog and a top toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar to the bottom`() {
+        val dialog = View(testContext)
+            .apply { id = R.id.viewDynamicDownloadDialog }
+            .also { parent.addView(it) }
+        View(testContext)
+            .apply { id = R.id.toolbar }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
+
+        // Test the scenario where the dialog is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        dialog.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+        // Test the scenario where the dialog is removed from parent.
+        dialog.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarPlacementAboveAnchor(dialog)
+        parent.removeView(dialog)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+    }
+
+    @Test
+    fun `GIVEN the snackbar is anchored based on a top toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
+        val toolbar = View(testContext)
+            .apply { id = R.id.toolbar }
+            .also { parent.addView(it) }
+        val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
+
+        // Test the scenario where the toolbar is invisible.
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+        toolbar.visibility = View.GONE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+        // Test the scenario where the toolbar is removed from parent.
+        toolbar.visibility = View.VISIBLE
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+        parent.removeView(toolbar)
+        behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+        assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+    }
+
+    private fun assertSnackbarPlacementAboveAnchor(anchor: View = dependency) {
+        assertEquals(anchor.id, snackbarContainer.params.anchorId)
+        assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.anchorGravity)
+        assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.gravity)
+    }
+
+    private fun assertSnackbarIsPlacedAtTheBottomOfTheScreen() {
+        assertEquals(View.NO_ID, snackbarContainer.params.anchorId)
+        assertEquals(Gravity.NO_GRAVITY, snackbarContainer.params.anchorGravity)
+        assertEquals(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.gravity)
+    }
+
+    private val FrameLayout.params
+        get() = layoutParams as CoordinatorLayout.LayoutParams
+}


=====================================
app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt
=====================================
@@ -0,0 +1,115 @@
+/* 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.downloads
+
+import android.app.Activity
+import android.widget.FrameLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.downloads.toMegabyteOrKilobyteString
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.StartDownloadDialogLayoutBinding
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+
+ at RunWith(FenixRobolectricTestRunner::class)
+class FirstPartyDownloadDialogTest {
+    private val activity: Activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+    @Before
+    fun setup() {
+        every { activity.settings().accessibilityServicesEnabled } returns false
+    }
+
+    @Test
+    fun `GIVEN the size of the download is known WHEN setting it's View THEN bind all provided download data and show the download size`() {
+        var wasPositiveActionDone = false
+        var wasNegativeActionDone = false
+        val contentSize = 5566L
+        val dialog = spyk(
+            FirstPartyDownloadDialog(
+                activity = activity,
+                filename = "Test",
+                contentSize = contentSize,
+                positiveButtonAction = { wasPositiveActionDone = true },
+                negativeButtonAction = { wasNegativeActionDone = true },
+            ),
+        )
+        every { dialog.dismiss() } just Runs
+        val dialogParent = FrameLayout(testContext)
+        dialog.container = dialogParent
+
+        dialog.setupView()
+
+        assertEquals(1, dialogParent.childCount)
+        assertEquals(R.id.dialogLayout, dialogParent.getChildAt(0).id)
+        val dialogBinding = dialog.binding as StartDownloadDialogLayoutBinding
+        assertEquals(
+            testContext.getString(
+                R.string.mozac_feature_downloads_dialog_title2,
+                contentSize.toMegabyteOrKilobyteString(),
+            ),
+            dialogBinding.title.text,
+        )
+        assertEquals("Test", dialogBinding.filename.text)
+        assertFalse(wasPositiveActionDone)
+        assertFalse(wasNegativeActionDone)
+        dialogBinding.downloadButton.callOnClick()
+        verify { dialog.dismiss() }
+        assertTrue(wasPositiveActionDone)
+        dialogBinding.closeButton.callOnClick()
+        verify(exactly = 2) { dialog.dismiss() }
+        assertTrue(wasNegativeActionDone)
+    }
+
+    @Test
+    fun `GIVEN the size of the download is not known WHEN setting it's View THEN bind all provided download data and show the download size`() {
+        var wasPositiveActionDone = false
+        var wasNegativeActionDone = false
+        val contentSize = 0L
+        val dialog = spyk(
+            FirstPartyDownloadDialog(
+                activity = activity,
+                filename = "Test",
+                contentSize = contentSize,
+                positiveButtonAction = { wasPositiveActionDone = true },
+                negativeButtonAction = { wasNegativeActionDone = true },
+            ),
+        )
+        every { dialog.dismiss() } just Runs
+        val dialogParent = FrameLayout(testContext)
+        dialog.container = dialogParent
+
+        dialog.setupView()
+
+        assertEquals(1, dialogParent.childCount)
+        assertEquals(R.id.dialogLayout, dialogParent.getChildAt(0).id)
+        val dialogBinding = dialog.binding as StartDownloadDialogLayoutBinding
+        assertEquals(
+            testContext.getString(R.string.mozac_feature_downloads_dialog_download),
+            dialogBinding.title.text,
+        )
+        assertEquals("Test", dialogBinding.filename.text)
+        assertFalse(wasPositiveActionDone)
+        assertFalse(wasNegativeActionDone)
+        dialogBinding.downloadButton.callOnClick()
+        verify { dialog.dismiss() }
+        assertTrue(wasPositiveActionDone)
+        dialogBinding.closeButton.callOnClick()
+        verify(exactly = 2) { dialog.dismiss() }
+        assertTrue(wasNegativeActionDone)
+    }
+}


=====================================
app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt
=====================================
@@ -0,0 +1,242 @@
+/* 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.downloads
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import mozilla.components.support.ktx.android.view.setNavigationBarTheme
+import mozilla.components.support.ktx.android.view.setStatusBarTheme
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.StartDownloadDialogLayoutBinding
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+
+ at RunWith(FenixRobolectricTestRunner::class)
+class StartDownloadDialogTest {
+    @Test
+    fun `WHEN the dialog is instantiated THEN cache the navigation and status bar colors`() {
+        val navigationBarColor = Color.RED
+        val statusBarColor = Color.BLUE
+        val activity: Activity = mockk {
+            every { window.navigationBarColor } returns navigationBarColor
+            every { window.statusBarColor } returns statusBarColor
+        }
+        val dialog = TestDownloadDialog(activity)
+
+        assertEquals(navigationBarColor, dialog.initialNavigationBarColor)
+        assertEquals(statusBarColor, dialog.initialStatusBarColor)
+    }
+
+    @Test
+    fun `WHEN the view is to be shown THEN set the scrim and other window customization bind the download values`() {
+        val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+        val dialogParent = FrameLayout(testContext)
+        val dialogContainer = FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+        }
+        val dialog = TestDownloadDialog(activity)
+
+        mockkStatic("mozilla.components.support.ktx.android.view.WindowKt", "org.mozilla.fenix.ext.ContextKt") {
+            every { any<Context>().settings() } returns mockk(relaxed = true)
+            val fluentDialog = dialog.show(dialogContainer)
+
+            val scrim = dialogParent.children.first { it.id == R.id.scrim }
+            assertTrue(scrim.hasOnClickListeners())
+            assertFalse(scrim.isSoundEffectsEnabled)
+            assertTrue(dialog.wasDownloadDataBinded)
+            assertEquals(
+                Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
+                (dialogContainer.layoutParams as CoordinatorLayout.LayoutParams).gravity,
+            )
+            assertEquals(
+                testContext.resources.getDimension(R.dimen.browser_fragment_download_dialog_elevation),
+                dialogContainer.elevation,
+            )
+            assertTrue(dialogContainer.isVisible)
+            verify {
+                activity.window.setNavigationBarTheme(ContextCompat.getColor(activity, R.color.material_scrim_color))
+                activity.window.setStatusBarTheme(ContextCompat.getColor(activity, R.color.material_scrim_color))
+            }
+            assertEquals(dialog, fluentDialog)
+        }
+    }
+
+    @Test
+    fun `GIVEN a dismiss callback WHEN the dialog is dismissed THEN the callback is informed`() {
+        var wasDismissCalled = false
+        val dialog = TestDownloadDialog(mockk(relaxed = true))
+
+        val fluentDialog = dialog.onDismiss { wasDismissCalled = true }
+        dialog.onDismiss()
+
+        assertTrue(wasDismissCalled)
+        assertEquals(dialog, fluentDialog)
+    }
+
+    @Test
+    fun `GIVEN the download dialog is shown WHEN dismissed THEN remove the scrim, the dialog and any window customizations`() {
+        val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+        val dialogParent = FrameLayout(testContext)
+        val dialogContainer = FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+        }
+        val dialog = TestDownloadDialog(activity)
+        mockkStatic("mozilla.components.support.ktx.android.view.WindowKt", "org.mozilla.fenix.ext.ContextKt") {
+            every { any<Context>().settings() } returns mockk(relaxed = true)
+            dialog.show(dialogContainer)
+            dialog.binding = StartDownloadDialogLayoutBinding
+                .inflate(LayoutInflater.from(activity), dialogContainer, true)
+
+            dialog.dismiss()
+
+            assertNull(dialogParent.children.firstOrNull { it.id == R.id.scrim })
+            assertTrue(dialogParent.childCount == 1)
+            assertTrue(dialogContainer.childCount == 0)
+            assertFalse(dialogContainer.isVisible)
+            verify {
+                activity.window.setNavigationBarTheme(dialog.initialNavigationBarColor)
+                activity.window.setStatusBarTheme(dialog.initialStatusBarColor)
+            }
+        }
+    }
+
+    @Test
+    fun `GIVEN a ViewGroup WHEN enabling accessibility THEN enable it for all children but the dialog container`() {
+        val activity: Activity = mockk(relaxed = true)
+        val dialogParent = FrameLayout(testContext)
+        FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.id = R.id.startDownloadDialogContainer
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+        }
+        val otherView = View(testContext).also {
+            dialogParent.addView(it)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+        }
+        val dialog = TestDownloadDialog(activity)
+
+        dialog.enableSiblingsAccessibility(dialogParent)
+
+        assertEquals(listOf(otherView), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+    }
+
+    @Test
+    fun `GIVEN a ViewGroup WHEN disabling accessibility THEN disable it for all children but the dialog container`() {
+        val activity: Activity = mockk(relaxed = true)
+        val dialogParent = FrameLayout(testContext)
+        val dialogContainer = FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.id = R.id.startDownloadDialogContainer
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+        View(testContext).also {
+            dialogParent.addView(it)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+        val dialog = TestDownloadDialog(activity)
+
+        dialog.disableSiblingsAccessibility(dialogParent)
+
+        assertEquals(listOf(dialogContainer), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+    }
+
+    @Test
+    fun `GIVEN accessibility services are enabled WHEN the dialog is shown THEN disable siblings accessibility`() {
+        val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+        val dialogParent = FrameLayout(testContext)
+        val dialogContainer = FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.id = R.id.startDownloadDialogContainer
+            it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+        View(testContext).also {
+            dialogParent.addView(it)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+
+        mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+            val dialog = TestDownloadDialog(activity)
+
+            val settings: Settings = mockk {
+                every { accessibilityServicesEnabled } returns false
+            }
+            every { any<Context>().settings() } returns settings
+            dialog.show(dialogContainer)
+            assertEquals(2, dialogParent.children.count { it.isImportantForAccessibility })
+
+            every { settings.accessibilityServicesEnabled } returns true
+            dialog.show(dialogContainer)
+            assertEquals(listOf(dialogContainer), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+        }
+    }
+
+    @Test
+    fun `WHEN the dialog is dismissed THEN re-enable siblings accessibility`() {
+        val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+        val dialogParent = FrameLayout(testContext)
+        val dialogContainer = FrameLayout(testContext).also {
+            dialogParent.addView(it)
+            it.id = R.id.startDownloadDialogContainer
+            it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+        val accessibleView = View(testContext).also {
+            dialogParent.addView(it)
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        }
+        mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+            val settings: Settings = mockk {
+                every { accessibilityServicesEnabled } returns true
+            }
+            every { any<Context>().settings() } returns settings
+            val dialog = TestDownloadDialog(activity)
+            dialog.show(dialogContainer)
+            dialog.binding = StartDownloadDialogLayoutBinding
+                .inflate(LayoutInflater.from(activity), dialogContainer, true)
+
+            dialog.dismiss()
+
+            assertEquals(
+                listOf(accessibleView),
+                dialogParent.children.filter { it.isVisible && it.isImportantForAccessibility }.toList(),
+            )
+        }
+    }
+}
+
+private class TestDownloadDialog(
+    activity: Activity,
+) : StartDownloadDialog(activity) {
+    var wasDownloadDataBinded = false
+
+    override fun setupView() {
+        wasDownloadDataBinded = true
+    }
+}


=====================================
app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt
=====================================
@@ -0,0 +1,54 @@
+/* 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.downloads
+
+import android.app.Activity
+import android.widget.FrameLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.downloads.databinding.MozacDownloaderChooserPromptBinding
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+
+ at RunWith(FenixRobolectricTestRunner::class)
+class ThirdPartyDownloadDialogTest {
+    private val activity: Activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+    @Test
+    fun `GIVEN a list of downloader apps WHEN setting it's View THEN bind all provided download data`() {
+        var wasNegativeActionDone = false
+        val dialog = spyk(
+            ThirdPartyDownloadDialog(
+                activity = activity,
+                downloaderApps = listOf(mockk(), mockk()),
+                onAppSelected = { /* cannot test the viewholder click */ },
+                negativeButtonAction = { wasNegativeActionDone = true },
+            ),
+        )
+        every { dialog.dismiss() } just Runs
+        val dialogParent = FrameLayout(testContext)
+        dialog.container = dialogParent
+
+        dialog.setupView()
+
+        assertEquals(1, dialogParent.childCount)
+        assertEquals(R.id.relativeLayout, dialogParent.getChildAt(0).id)
+        val dialogBinding = dialog.binding as MozacDownloaderChooserPromptBinding
+        assertEquals(2, dialogBinding.appsList.adapter?.itemCount)
+        dialogBinding.closeButton.callOnClick()
+        assertTrue(wasNegativeActionDone)
+        verify { dialog.dismiss() }
+    }
+}


=====================================
app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt
=====================================
@@ -6,16 +6,29 @@ package org.mozilla.fenix.tabstray.ext
 
 import android.content.Context
 import android.view.View
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
 import io.mockk.every
 import io.mockk.mockk
+import io.mockk.mockkStatic
 import io.mockk.verifyOrder
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
 import org.junit.Rule
 import org.junit.Test
+import org.junit.runner.RunWith
 import org.mozilla.fenix.R
 import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.FenixSnackbarBehavior
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
 import org.mozilla.fenix.helpers.MockkRetryTestRule
 import org.mozilla.fenix.tabstray.TabsTrayFragment.Companion.ELEVATION
+import org.mozilla.fenix.utils.Settings
 
+ at RunWith(FenixRobolectricTestRunner::class)
 class FenixSnackbarKtTest {
 
     @get:Rule
@@ -94,4 +107,24 @@ class FenixSnackbarKtTest {
             snackbar.setAction("test1", any())
         }
     }
+
+    @Test
+    fun `GIVEN the snackbar is a child of dynamic container WHEN it is shown THEN enable the dynamic behavior`() {
+        val container = FrameLayout(testContext).apply {
+            id = R.id.dynamicSnackbarContainer
+            layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+        }
+        val settings: Settings = mockk(relaxed = true) {
+            every { toolbarPosition } returns ToolbarPosition.BOTTOM
+        }
+        mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+            every { any<Context>().settings() } returns settings
+
+            FenixSnackbar.make(view = container)
+
+            val behavior = (container.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior
+            assertTrue(behavior is FenixSnackbarBehavior)
+            assertEquals(ToolbarPosition.BOTTOM, (behavior as? FenixSnackbarBehavior)?.toolbarPosition)
+        }
+    }
 }



View it on GitLab: https://gitlab.torproject.org/tpo/applications/fenix/-/compare/667092ef6caed0c7524e4cd717079bd159a556dd...10a73978059a0decd4fd3eb4f8e82b5b974d5582

-- 
View it on GitLab: https://gitlab.torproject.org/tpo/applications/fenix/-/compare/667092ef6caed0c7524e4cd717079bd159a556dd...10a73978059a0decd4fd3eb4f8e82b5b974d5582
You're receiving this email because of your account on gitlab.torproject.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.torproject.org/pipermail/tor-commits/attachments/20230316/169c2d66/attachment-0001.htm>


More information about the tor-commits mailing list