Commit 7b7310d1 authored by MozLando's avatar MozLando
Browse files

Merge #6916 #6947



6916: Closes #6915: Add addon installation confirmation dialog r=Amejia481,csadilek a=psymoon



6947: Closes #6791: Intermittent test failure in WebExtensionSupport r=psymoon a=csadilek

Basically, we dispatch a tab browser action to test that our action handler works, but that in turn changes the tab state triggering our flow again (see registerHandlersForNewSession). We can simply verify and capture the handlers first, then trigger actions to test them.

Co-authored-by: default avatarSimon Chae <chaesmn@gmail.com>
Co-authored-by: default avatarChristian Sadilek <christian.sadilek@gmail.com>
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -34,6 +34,11 @@ android {
    }
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
    kotlinOptions.freeCompilerArgs += [
            "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi"
    ]
}

dependencies {
    implementation Dependencies.androidx_appcompat
+265 −0
Original line number Diff line number Diff line
/* 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 mozilla.components.feature.addons.ui

import android.annotation.SuppressLint
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.widget.AppCompatCheckBox
import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.mozac_feature_addons_fragment_dialog_addon_installed.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.amo.AddonCollectionProvider
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import java.io.IOException

private const val KEY_DIALOG_GRAVITY = "KEY_DIALOG_GRAVITY"
private const val KEY_DIALOG_WIDTH_MATCH_PARENT = "KEY_DIALOG_WIDTH_MATCH_PARENT"
private const val KEY_CONFIRM_BUTTON_BACKGROUND_COLOR = "KEY_CONFIRM_BUTTON_BACKGROUND_COLOR"
private const val KEY_CONFIRM_BUTTON_TEXT_COLOR = "KEY_CONFIRM_BUTTON_TEXT_COLOR"
private const val KEY_CONFIRM_BUTTON_RADIUS = "KEY_CONFIRM_BUTTON_RADIUS"
private const val DEFAULT_VALUE = Int.MAX_VALUE

/**
 * A dialog that shows [Addon] installation confirmation.
 */
class AddonInstallationDialogFragment(
    private val addonCollectionProvider: AddonCollectionProvider
) : AppCompatDialogFragment() {
    private val scope = CoroutineScope(Dispatchers.IO)
    private val logger = Logger("AddonInstallationDialogFragment")
    /**
     * A lambda called when the confirm button is clicked.
     */
    var onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null

    private val safeArguments get() = requireNotNull(arguments)

    internal val addon get() = requireNotNull(safeArguments.getParcelable<Addon>(KEY_ADDON))
    private var allowPrivateBrowsing: Boolean = false

    internal val confirmButtonRadius
        get() =
            safeArguments.getFloat(KEY_CONFIRM_BUTTON_RADIUS, DEFAULT_VALUE.toFloat())

    internal val dialogGravity: Int
        get() =
            safeArguments.getInt(
                KEY_DIALOG_GRAVITY,
                DEFAULT_VALUE
            )
    internal val dialogShouldWidthMatchParent: Boolean
        get() =
            safeArguments.getBoolean(KEY_DIALOG_WIDTH_MATCH_PARENT)

    internal val confirmButtonBackgroundColor
        get() =
            safeArguments.getInt(
                KEY_CONFIRM_BUTTON_BACKGROUND_COLOR,
                DEFAULT_VALUE
            )

    internal val confirmButtonTextColor
        get() =
            safeArguments.getInt(
                KEY_CONFIRM_BUTTON_TEXT_COLOR,
                DEFAULT_VALUE
            )

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val sheetDialog = Dialog(requireContext())
        sheetDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
        sheetDialog.setCanceledOnTouchOutside(true)

        val rootView = createContainer()

        sheetDialog.setContainerView(rootView)

        sheetDialog.window?.apply {
            if (dialogGravity != DEFAULT_VALUE) {
                setGravity(dialogGravity)
            }

            if (dialogShouldWidthMatchParent) {
                setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
                // This must be called after addContentView, or it won't fully fill to the edge.
                setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
            }
        }

        return sheetDialog
    }

    private fun Dialog.setContainerView(rootView: View) {
        if (dialogShouldWidthMatchParent) {
            setContentView(rootView)
        } else {
            addContentView(
                rootView,
                LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.MATCH_PARENT,
                    LinearLayout.LayoutParams.MATCH_PARENT
                )
            )
        }
    }

    @SuppressLint("InflateParams")
    private fun createContainer(): View {
        val rootView = LayoutInflater.from(requireContext()).inflate(
            R.layout.mozac_feature_addons_fragment_dialog_addon_installed,
            null,
            false
        )

        rootView.findViewById<TextView>(R.id.title).text =
            requireContext().getString(
                R.string.mozac_feature_addons_installed_dialog_title,
                addon.translatedName,
                requireContext().appName
            )

        fetchIcon(addon, rootView.icon)

        val allowedInPrivateBrowsing = rootView.findViewById<AppCompatCheckBox>(R.id.allow_in_private_browsing)
        allowedInPrivateBrowsing.setOnCheckedChangeListener { _, isChecked ->
            allowPrivateBrowsing = isChecked
        }

        val confirmButton = rootView.findViewById<Button>(R.id.confirm_button)
        confirmButton.setOnClickListener {
            onConfirmButtonClicked?.invoke(addon, allowPrivateBrowsing)
            dismiss()
        }

        if (confirmButtonBackgroundColor != DEFAULT_VALUE) {
            val backgroundTintList =
                    ContextCompat.getColorStateList(requireContext(), confirmButtonBackgroundColor)
            confirmButton.backgroundTintList = backgroundTintList
        }

        if (confirmButtonTextColor != DEFAULT_VALUE) {
            val color = ContextCompat.getColor(requireContext(), confirmButtonTextColor)
            confirmButton.setTextColor(color)
        }

        if (confirmButtonRadius != DEFAULT_VALUE.toFloat()) {
            val shape = GradientDrawable()
            shape.shape = GradientDrawable.RECTANGLE
            shape.setColor(
                ContextCompat.getColor(
                    requireContext(),
                    confirmButtonBackgroundColor
                )
            )
            shape.cornerRadius = confirmButtonRadius
            confirmButton.background = shape
        }

        return rootView
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    internal fun fetchIcon(addon: Addon, iconView: ImageView, scope: CoroutineScope = this.scope): Job {
        return scope.launch {
            try {
                val iconBitmap = addonCollectionProvider.getAddonIconBitmap(addon)
                iconBitmap?.let {
                    scope.launch(Dispatchers.Main) {
                        iconView.setImageDrawable(BitmapDrawable(iconView.resources, it))
                    }
                }
            } catch (e: IOException) {
                scope.launch(Dispatchers.Main) {
                    val context = iconView.context
                    val att = context.theme.resolveAttribute(android.R.attr.textColorPrimary)
                    iconView.setColorFilter(ContextCompat.getColor(context, att))
                    iconView.setImageDrawable(context.getDrawable(R.drawable.mozac_ic_extensions))
                }
                logger.error("Attempt to fetch the ${addon.id} icon failed", e)
            }
        }
    }

    @Suppress("LongParameterList")
    companion object {
        /**
         * Returns a new instance of [AddonInstallationDialogFragment].
         * @param addon The addon to show in the dialog.
         * @param promptsStyling Styling properties for the dialog.
         * @param onConfirmButtonClicked A lambda called when the confirm button is clicked.
         */
        fun newInstance(
            addon: Addon,
            addonCollectionProvider: AddonCollectionProvider,
            promptsStyling: PromptsStyling? = PromptsStyling(
                gravity = Gravity.BOTTOM,
                shouldWidthMatchParent = true
            ),
            onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null
        ): AddonInstallationDialogFragment {

            val fragment = AddonInstallationDialogFragment(addonCollectionProvider)
            val arguments = fragment.arguments ?: Bundle()

            arguments.apply {
                putParcelable(KEY_ADDON, addon)

                promptsStyling?.gravity?.apply {
                    putInt(KEY_DIALOG_GRAVITY, this)
                }
                promptsStyling?.shouldWidthMatchParent?.apply {
                    putBoolean(KEY_DIALOG_WIDTH_MATCH_PARENT, this)
                }
                promptsStyling?.confirmButtonBackgroundColor?.apply {
                    putInt(KEY_CONFIRM_BUTTON_BACKGROUND_COLOR, this)
                }

                promptsStyling?.confirmButtonTextColor?.apply {
                    putInt(KEY_CONFIRM_BUTTON_TEXT_COLOR, this)
                }
            }
            fragment.onConfirmButtonClicked = onConfirmButtonClicked
            fragment.arguments = arguments
            return fragment
        }
    }

    /**
     * Styling for the addon installation dialog.
     */
    data class PromptsStyling(
        val gravity: Int,
        val shouldWidthMatchParent: Boolean = false,
        @ColorRes
        val confirmButtonBackgroundColor: Int? = null,
        @ColorRes
        val confirmButtonTextColor: Int? = null,
        val confirmButtonRadius: Float? = null
    )
}
+90 −0
Original line number Diff line number Diff line
<!-- 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:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?android:windowBackground"
    android:orientation="vertical"
    tools:ignore="Overdraw">

    <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="fitCenter"
        app:srcCompat="@drawable/mozac_ic_extensions" />

    <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_toEndOf="@id/icon"
        android:paddingStart="5dp"
        android:paddingTop="4dp"
        android:paddingEnd="5dp"
        android:textColor="?android:attr/textColorPrimary"
        android:textSize="16sp"
        tools:text="@string/mozac_feature_addons_installed_dialog_title"
        tools:textColor="#000000" />

    <TextView
        android:id="@+id/description"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/title"
        android:layout_alignStart="@id/title"
        android:layout_marginTop="16dp"
        android:paddingStart="5dp"
        android:paddingTop="4dp"
        android:paddingEnd="5dp"
        android:textColor="?android:attr/textColorPrimary"
        android:text="@string/mozac_feature_addons_installed_dialog_description" />

    <androidx.appcompat.widget.AppCompatImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toEndOf="@+id/description"
        android:layout_below="@id/title"
        android:layout_marginTop="16dp"
        app:tint="?android:attr/textColorPrimary"
        app:srcCompat="@drawable/mozac_ic_menu"
        />

    <androidx.appcompat.widget.AppCompatCheckBox
        android:id="@+id/allow_in_private_browsing"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/description"
        android:layout_alignStart="@id/title"
        android:layout_marginTop="16dp"
        android:paddingStart="5dp"
        android:paddingTop="4dp"
        android:paddingEnd="5dp"
        android:text="@string/mozac_feature_addons_settings_allow_in_private_browsing" />

    <Button
        android:id="@+id/confirm_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/allow_in_private_browsing"
        android:layout_alignParentEnd="true"
        android:layout_marginStart="8dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginBottom="16dp"
        android:text="@string/mozac_feature_addons_installed_dialog_okay_button"
        android:textAllCaps="false" />

</RelativeLayout>
 No newline at end of file
+7 −1
Original line number Diff line number Diff line
@@ -181,4 +181,10 @@
    <string name="mozac_feature_addons_updater_dialog_last_attempt">Last attempt:</string>
    <!-- Displayed in the "Status" field for the updater when an add-on has been correctly updated -->
    <string name="mozac_feature_addons_updater_dialog_status">Status:</string>
    <!-- Text shown in the dialog when add-on installation is completed. %1$s is the add-on name. %2$s is the app name. -->
    <string name="mozac_feature_addons_installed_dialog_title">%1$s has been added to %2$s</string>
    <!-- Text shown in the dialog when add-on installation is completed. %1$s is the add-on name. -->
    <string name="mozac_feature_addons_installed_dialog_description">Open it in the menu</string>
    <!-- Confirmation button text for the dialog when add-on installation is completed. -->
    <string name="mozac_feature_addons_installed_dialog_okay_button">Okay, Got It</string>
</resources>
+182 −0
Original line number Diff line number Diff line
/* 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 mozilla.components.feature.addons.amo.mozilla.components.feature.addons.ui

import android.graphics.Bitmap
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatCheckBox
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.amo.AddonCollectionProvider
import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment
import mozilla.components.feature.addons.ui.translatedName
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.whenever
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
import java.io.IOException

@RunWith(AndroidJUnit4::class)
class AddonInstallationDialogFragmentTest {
    private val testDispatcher = TestCoroutineDispatcher()
    private val scope = TestCoroutineScope(testDispatcher)
    @get:Rule
    val coroutinesTestRule = MainCoroutineRule(testDispatcher)

    @Test
    fun `build dialog`() {
        val addon = Addon(
            "id", translatableName = mapOf(Addon.DEFAULT_LOCALE to "my_addon"),
            permissions = listOf("privacy", "<all_urls>", "tabs")
        )
        val fragment = createAddonInstallationDialogFragment(addon, mock())

        doReturn(testContext).`when`(fragment).requireContext()
        val dialog = fragment.onCreateDialog(null)
        dialog.show()
        val name = addon.translatedName
        val titleTextView = dialog.findViewById<TextView>(R.id.title)
        val description = dialog.findViewById<TextView>(R.id.description)
        val allowedInPrivateBrowsing = dialog.findViewById<AppCompatCheckBox>(R.id.allow_in_private_browsing)

        assertTrue(titleTextView.text.contains(name))
        assertTrue(description.text.contains(testContext.getString(R.string.mozac_feature_addons_installed_dialog_description)))
        assertTrue(allowedInPrivateBrowsing.text.contains(testContext.getString(R.string.mozac_feature_addons_settings_allow_in_private_browsing)))
    }

    @Test
    fun `clicking on confirm dialog buttons notifies lambda with private browsing boolean`() {
        val addon = Addon("id", translatableName = mapOf(Addon.DEFAULT_LOCALE to "my_addon"))

        val fragment = createAddonInstallationDialogFragment(addon, mock())
        var confirmationWasExecuted = false
        var allowInPrivateBrowsing = false

        fragment.onConfirmButtonClicked = { _, allow ->
            confirmationWasExecuted = true
            allowInPrivateBrowsing = allow
        }

        doReturn(testContext).`when`(fragment).requireContext()
        doReturn(mockFragmentManager()).`when`(fragment).fragmentManager

        val dialog = fragment.onCreateDialog(null)
        dialog.show()
        val confirmButton = dialog.findViewById<Button>(R.id.confirm_button)
        val allowedInPrivateBrowsing = dialog.findViewById<AppCompatCheckBox>(R.id.allow_in_private_browsing)
        confirmButton.performClick()
        assertTrue(confirmationWasExecuted)
        assertFalse(allowInPrivateBrowsing)

        dialog.show()
        allowedInPrivateBrowsing.performClick()
        confirmButton.performClick()
        assertTrue(confirmationWasExecuted)
        assertTrue(allowInPrivateBrowsing)
    }

    @Test
    fun `dismissing the dialog notifies nothing`() {
        val addon = Addon("id", translatableName = mapOf(Addon.DEFAULT_LOCALE to "my_addon"))
        val fragment = createAddonInstallationDialogFragment(addon, mock())
        var confirmationWasExecuted = false

        fragment.onConfirmButtonClicked = { _, _ ->
            confirmationWasExecuted = true
        }

        doReturn(testContext).`when`(fragment).requireContext()
        doReturn(mockFragmentManager()).`when`(fragment).fragmentManager

        val dialog = fragment.onCreateDialog(null)
        dialog.show()
        fragment.onDismiss(mock())
        assertFalse(confirmationWasExecuted)
    }

    @Test
    fun `dialog must have all the styles of the feature promptsStyling object`() {
        val addon = Addon("id", translatableName = mapOf(Addon.DEFAULT_LOCALE to "my_addon"))
        val styling = AddonInstallationDialogFragment.PromptsStyling(Gravity.TOP, true)
        val fragment = createAddonInstallationDialogFragment(addon, mock(), styling)

        doReturn(testContext).`when`(fragment).requireContext()
        val dialog = fragment.onCreateDialog(null)
        val dialogAttributes = dialog.window!!.attributes

        assertTrue(dialogAttributes.gravity == Gravity.TOP)
        assertTrue(dialogAttributes.width == ViewGroup.LayoutParams.MATCH_PARENT)
    }

    @Test
    fun `fetching the add-on icon() successfully `() {
        val addon = mock<Addon>()
        val bitmap = mock<Bitmap>()
        val mockedImageView = spy(ImageView(testContext))
        val mockedCollectionProvider = mock<AddonCollectionProvider>()
        val fragment = createAddonInstallationDialogFragment(addon, mockedCollectionProvider)

        runBlocking {
            whenever(mockedCollectionProvider.getAddonIconBitmap(addon)).thenReturn(bitmap)
            fragment.fetchIcon(addon, mockedImageView, scope).join()
            verify(mockedImageView).setImageDrawable(Mockito.any())
        }
    }

    @Test
    fun `handle errors while fetching the add-on icon() `() {
        val addon = mock<Addon>()
        val mockedImageView = spy(ImageView(testContext))
        val mockedCollectionProvider = mock<AddonCollectionProvider>()
        val fragment = createAddonInstallationDialogFragment(addon, mockedCollectionProvider)

        runBlocking {
            whenever(mockedCollectionProvider.getAddonIconBitmap(addon)).then {
                throw IOException("Request failed")
            }
            try {
                fragment.fetchIcon(addon, mockedImageView, scope).join()
                verify(mockedImageView).setColorFilter(Mockito.anyInt())
            } catch (e: IOException) {
                fail("The exception must be handle in the adapter")
            }
        }
    }

    private fun createAddonInstallationDialogFragment(
        addon: Addon,
        addonCollectionProvider: AddonCollectionProvider,
        promptsStyling: AddonInstallationDialogFragment.PromptsStyling? = null
    ): AddonInstallationDialogFragment {
        return spy(AddonInstallationDialogFragment.newInstance(addon, addonCollectionProvider, promptsStyling = promptsStyling))
    }

    private fun mockFragmentManager(): FragmentManager {
        val fragmentManager: FragmentManager = mock()
        val transaction: FragmentTransaction = mock()
        doReturn(transaction).`when`(fragmentManager).beginTransaction()
        return fragmentManager
    }
}
Loading