Bug 1848100 - Implement add-ons installation failed listener

fenix/119.0
Arturo Mejia 1 year ago committed by mergify[bot]
parent ccddbd3319
commit 61431c6b59

@ -21,12 +21,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.AddonManagerException
import mozilla.components.feature.addons.ui.AddonsManagerAdapter
import mozilla.components.feature.addons.ui.translateName
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.Config
@ -34,14 +32,12 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.FragmentAddOnsManagementBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.extension.WebExtensionPromptFeature
import org.mozilla.fenix.theme.ThemeManager
import java.util.concurrent.CancellationException
/**
* Fragment use for managing add-ons.
@ -56,11 +52,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private var addons: List<Addon> = emptyList()
/**
* Whether or not an add-on installation is in progress.
*/
private var isInstallationInProgress = false
private var installExternalAddonComplete: Boolean
set(value) {
arguments?.putBoolean(BUNDLE_KEY_INSTALL_EXTERNAL_ADDON_COMPLETE, value)
@ -138,7 +129,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
excludedAddonIDs,
)
}
isInstallationInProgress = false
binding?.addOnsProgressBar?.isVisible = false
binding?.addOnsEmptyMessage?.isVisible = false
@ -163,7 +153,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
getString(R.string.mozac_feature_addons_failed_to_query_add_ons),
)
}
isInstallationInProgress = false
binding?.addOnsProgressBar?.isVisible = false
binding?.addOnsEmptyMessage?.isVisible = true
}
@ -230,30 +219,12 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
addon,
onSuccess = {
runIfFragmentIsAttached {
isInstallationInProgress = false
adapter?.updateAddon(it)
binding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE
}
},
onError = { _, e ->
this@AddonsManagementFragment.view?.let { view ->
// No need to display an error message if installation was cancelled by the user.
if (e !is CancellationException && e !is WebExtensionInstallException.UserCancelled) {
val rootView = activity?.getRootView() ?: view
var messageId = R.string.mozac_feature_addons_failed_to_install
if (e is WebExtensionInstallException.Blocklisted) {
messageId = R.string.mozac_feature_addons_blocklisted
}
context?.let {
showErrorSnackBar(
text = getString(messageId, addon.translateName(it)),
anchorView = rootView,
)
}
}
binding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE
isInstallationInProgress = false
}
onError = { _, _ ->
binding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE
},
)
binding?.addonProgressOverlay?.cancelButton?.setOnClickListener {

@ -7,6 +7,7 @@ package org.mozilla.fenix.extension
import android.content.Context
import android.view.Gravity
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
@ -15,12 +16,15 @@ import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.toInstalledState
import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment
import mozilla.components.feature.addons.ui.PermissionsDialogFragment
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.android.content.appVersionName
import mozilla.components.ui.widgets.withCenterAlignedButtons
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.theme.ThemeManager
@ -51,26 +55,49 @@ class WebExtensionPromptFeature(
flow.mapNotNull { state ->
state.webExtensionPromptRequest
}.distinctUntilChanged().collect { promptRequest ->
// The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView)
// extension into a minimal add-on. The missing metadata will be fetched when the user
// opens the add-ons manager.
val addon = Addon.newFromWebExtension(promptRequest.extension)
when (promptRequest) {
is WebExtensionPromptRequest.Permissions -> handlePermissionRequest(
addon,
promptRequest,
)
is WebExtensionPromptRequest.AfterInstallation -> {
handleAfterInstallationRequest(promptRequest)
}
is WebExtensionPromptRequest.PostInstallation -> handlePostInstallationRequest(
addon.copy(installedState = promptRequest.extension.toInstalledState()),
)
is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> {
handleBeforeInstallationRequest(promptRequest)
consumePromptRequest()
}
}
}
}
tryToReAttachButtonHandlersToPreviousDialog()
}
private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) {
// The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView)
// extension into a minimal add-on. The missing metadata will be fetched when the user
// opens the add-ons manager.
val addon = Addon.newFromWebExtension(promptRequest.extension)
when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions -> handlePermissionRequest(
addon,
promptRequest,
)
is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> handlePostInstallationRequest(
addon.copy(installedState = promptRequest.extension.toInstalledState()),
)
}
}
private fun handleBeforeInstallationRequest(promptRequest: WebExtensionPromptRequest.BeforeInstallation) {
when (promptRequest) {
is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> {
handleInstallationFailedRequest(
exception = promptRequest.exception,
)
consumePromptRequest()
}
}
}
private fun handlePostInstallationRequest(
addon: Addon,
) {
@ -79,13 +106,73 @@ class WebExtensionPromptFeature(
private fun handlePermissionRequest(
addon: Addon,
promptRequest: WebExtensionPromptRequest.Permissions,
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
) {
if (hasExistingPermissionDialogFragment()) {
return
if (hasExistingPermissionDialogFragment()) return
showPermissionDialog(
addon,
promptRequest,
)
}
@VisibleForTesting
internal fun handleInstallationFailedRequest(
exception: WebExtensionInstallException,
) {
val addonName = exception.extensionName ?: ""
var title = context.getString(R.string.mozac_feature_addons_failed_to_install, "")
val message = when (exception) {
is WebExtensionInstallException.Blocklisted -> {
context.getString(R.string.mozac_feature_addons_blocklisted, addonName)
}
is WebExtensionInstallException.UserCancelled -> {
// We don't want to show an error message when users cancel installation.
return
}
is WebExtensionInstallException.Unknown -> {
// Making sure we don't have a
// Title = Failed to install
// Message = Failed to install $addonName
title = ""
if (addonName.isNotEmpty()) {
context.getString(R.string.mozac_feature_addons_failed_to_install, addonName)
} else {
context.getString(R.string.mozac_feature_addons_failed_to_install_generic)
}
}
is WebExtensionInstallException.NetworkFailure -> {
context.getString(R.string.mozac_feature_addons_failed_to_install_network_error)
}
is WebExtensionInstallException.CorruptFile -> {
context.getString(R.string.mozac_feature_addons_failed_to_install_corrupt_error)
}
is WebExtensionInstallException.NotSigned -> {
context.getString(
R.string.mozac_feature_addons_failed_to_install_not_signed_error,
)
}
is WebExtensionInstallException.Incompatible -> {
val appName = context.getString(R.string.app_name)
val version = context.appVersionName
context.getString(
R.string.mozac_feature_addons_failed_to_install_incompatible_error,
addonName,
appName,
version,
)
}
}
showPermissionDialog(addon, promptRequest)
showDialog(
title = title,
message = message,
)
}
/**
@ -98,7 +185,7 @@ class WebExtensionPromptFeature(
@VisibleForTesting
internal fun showPermissionDialog(
addon: Addon,
promptRequest: WebExtensionPromptRequest.Permissions,
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
) {
if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) {
val dialog = PermissionsDialogFragment.newInstance(
@ -135,8 +222,8 @@ class WebExtensionPromptFeature(
findPreviousPermissionDialogFragment()?.let { dialog ->
dialog.onPositiveButtonClicked = { addon ->
store.state.webExtensionPromptRequest?.let { promptRequest ->
if (addon.id == promptRequest.extension.id &&
promptRequest is WebExtensionPromptRequest.Permissions
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions &&
addon.id == promptRequest.extension.id
) {
handleApprovedPermissions(promptRequest)
}
@ -144,7 +231,7 @@ class WebExtensionPromptFeature(
}
dialog.onNegativeButtonClicked = {
store.state.webExtensionPromptRequest?.let { promptRequest ->
if (promptRequest is WebExtensionPromptRequest.Permissions) {
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions) {
handleDeniedPermissions(promptRequest)
}
}
@ -154,8 +241,8 @@ class WebExtensionPromptFeature(
findPreviousPostInstallationDialogFragment()?.let { dialog ->
dialog.onConfirmButtonClicked = { addon, allowInPrivateBrowsing ->
store.state.webExtensionPromptRequest?.let { promptRequest ->
if (addon.id == promptRequest.extension.id &&
promptRequest is WebExtensionPromptRequest.PostInstallation
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.PostInstallation &&
addon.id == promptRequest.extension.id
) {
handlePostInstallationButtonClicked(
allowInPrivateBrowsing = allowInPrivateBrowsing,
@ -173,12 +260,12 @@ class WebExtensionPromptFeature(
}
}
private fun handleDeniedPermissions(promptRequest: WebExtensionPromptRequest.Permissions) {
private fun handleDeniedPermissions(promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions) {
promptRequest.onConfirm(false)
consumePromptRequest()
}
private fun handleApprovedPermissions(promptRequest: WebExtensionPromptRequest.Permissions) {
private fun handleApprovedPermissions(promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions) {
promptRequest.onConfirm(true)
consumePromptRequest()
}
@ -267,6 +354,24 @@ class WebExtensionPromptFeature(
consumePromptRequest()
}
@VisibleForTesting
internal fun showDialog(
title: String,
message: String,
) {
context.let {
AlertDialog.Builder(it)
.setTitle(title)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.setCancelable(false)
.setMessage(
message,
)
.show()
.withCenterAlignedButtons()
}
}
companion object {
private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT"
private const val POST_INSTALLATION_DIALOG_FRAGMENT_TAG =

@ -6,15 +6,11 @@ package org.mozilla.fenix.addons
import android.content.Context
import androidx.coordinatorlayout.widget.CoordinatorLayout
import io.mockk.CapturingSlot
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.ui.translateName
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.R
@ -61,24 +57,4 @@ class AddonsManagementFragmentTest {
fragment.installExternalAddon(supportedAddons, "d1")
verify { fragment.showErrorSnackBar(addonAlreadyInstalledErrorMessage) }
}
@Test
fun `GIVEN add-on is installed WHEN add-on is blocklisted THEN error is shown`() {
val addonManger = mockk<AddonManager>()
val addon = Addon("1")
val onError = CapturingSlot<((String, Throwable) -> Unit)>()
val expectedErrorMessage = fragment.getString(
R.string.mozac_feature_addons_blocklisted,
addon.translateName(context),
)
every { fragment.provideAccessibilityServicesEnabled() } returns false
every { fragment.provideAddonManger() } returns addonManger
every { addonManger.installAddon(addon, any(), capture(onError)) } returns mockk()
fragment.installAddon(addon)
onError.captured("", WebExtensionInstallException.Blocklisted(mockk()))
verify { fragment.showErrorSnackBar(expectedErrorMessage) }
}
}

@ -0,0 +1,219 @@
/* 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.extension
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.browser.state.action.WebExtensionAction.UpdatePromptRequestWebExtensionAction
import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.support.ktx.android.content.appVersionName
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class WebExtensionPromptFeatureTest {
private lateinit var webExtensionPromptFeature: WebExtensionPromptFeature
private lateinit var store: BrowserStore
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Before
fun setup() {
store = BrowserStore()
webExtensionPromptFeature = spyk(
WebExtensionPromptFeature(
store = store,
context = testContext,
fragmentManager = mockk(relaxed = true),
),
)
}
@Test
fun `WHEN InstallationFailed is dispatched THEN handleInstallationFailedRequest is called`() {
webExtensionPromptFeature.start()
every { webExtensionPromptFeature.handleInstallationFailedRequest(any()) } just runs
store.dispatch(
UpdatePromptRequestWebExtensionAction(
WebExtensionPromptRequest.BeforeInstallation.InstallationFailed(
mockk(),
mockk(),
),
),
).joinBlocking()
verify { webExtensionPromptFeature.handleInstallationFailedRequest(any()) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with network error THEN showDialog with the correct message`() {
val expectedTitle =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
val exception = WebExtensionInstallException.NetworkFailure(
extensionName = "name",
throwable = Exception(),
)
val expectedMessage =
testContext.getString(
R.string.mozac_feature_addons_failed_to_install_network_error,
"name",
)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with Blocklisted error THEN showDialog with the correct message`() {
val expectedTitle =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
val extensionName = "extensionName"
val exception = WebExtensionInstallException.Blocklisted(
extensionName = extensionName,
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_blocklisted, extensionName)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with UserCancelled error THEN do not showDialog`() {
val expectedTitle = ""
val extensionName = "extensionName"
val exception = WebExtensionInstallException.UserCancelled(
extensionName = extensionName,
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, extensionName)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify(exactly = 0) { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with Unknown error THEN showDialog with the correct message`() {
val expectedTitle = ""
val extensionName = "extensionName"
val exception = WebExtensionInstallException.Unknown(
extensionName = extensionName,
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, extensionName)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with Unknown error and no extension name THEN showDialog with the correct message`() {
val expectedTitle = ""
val exception = WebExtensionInstallException.Unknown(
extensionName = null,
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_failed_to_install_generic)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with CorruptFile error THEN showDialog with the correct message`() {
val expectedTitle =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
val exception = WebExtensionInstallException.CorruptFile(
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_failed_to_install_corrupt_error)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with NotSigned error THEN showDialog with the correct message`() {
val expectedTitle =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
val exception = WebExtensionInstallException.NotSigned(
throwable = Exception(),
)
val expectedMessage =
testContext.getString(R.string.mozac_feature_addons_failed_to_install_not_signed_error)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
@Test
fun `WHEN calling handleInstallationFailedRequest with Incompatible error THEN showDialog with the correct message`() {
val expectedTitle =
testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
val extensionName = "extensionName"
val exception = WebExtensionInstallException.Incompatible(
extensionName = extensionName,
throwable = Exception(),
)
val appName = testContext.getString(R.string.app_name)
val version = testContext.appVersionName
val expectedMessage =
testContext.getString(
R.string.mozac_feature_addons_failed_to_install_incompatible_error,
extensionName,
appName,
version,
)
webExtensionPromptFeature.handleInstallationFailedRequest(
exception = exception,
)
verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
}
}
Loading…
Cancel
Save