diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt index aa6f4332c2..eebdc56cfc 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -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() private var addons: List = 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 { diff --git a/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt b/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt index de69be0ff8..1deb041e30 100644 --- a/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt @@ -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 = diff --git a/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt b/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt index 8c57ee1c26..235bebe2e4 100644 --- a/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt +++ b/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt @@ -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() - 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) } - } } diff --git a/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt b/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt new file mode 100644 index 0000000000..af5efe5f76 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt @@ -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) } + } +}