diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAddonsManagerRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAddonsManagerRobot.kt index 8c2292d623..c82834595f 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAddonsManagerRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAddonsManagerRobot.kt @@ -186,6 +186,8 @@ class SettingsSubMenuAddonsManagerRobot { } private fun assertAddonPermissionPrompt(addonName: String) { + mDevice.waitNotNull(Until.findObject(By.text("Add $addonName?")), waitingTime) + onView(allOf(withId(R.id.title), withText("Add $addonName?"))) .check(matches(isCompletelyDisplayed())) @@ -221,6 +223,8 @@ class SettingsSubMenuAddonsManagerRobot { } private fun allowPermissionToInstall() { + mDevice.waitNotNull(Until.findObject(By.text("Add")), waitingTime) + onView(allOf(withId(R.id.allow_button), withText("Add"))) .check(matches(isCompletelyDisplayed())) .perform(click()) 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 c34c21487c..33ec4d3fde 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -11,7 +11,6 @@ import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.View -import android.view.accessibility.AccessibilityEvent import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -26,8 +25,8 @@ import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.AddonManagerException import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment import mozilla.components.feature.addons.ui.AddonsManagerAdapter -import mozilla.components.feature.addons.ui.PermissionsDialogFragment import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.Config import org.mozilla.fenix.R @@ -35,9 +34,10 @@ 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.lang.ref.WeakReference import java.util.concurrent.CancellationException @@ -52,6 +52,9 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) private var binding: FragmentAddOnsManagementBinding? = null + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() + private lateinit var addons: List + /** * Whether or not an add-on installation is in progress. */ @@ -71,6 +74,17 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) super.onViewCreated(view, savedInstanceState) binding = FragmentAddOnsManagementBinding.bind(view) bindRecyclerView() + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = requireComponents.core.store, + provideAddons = { addons }, + context = requireContext(), + fragmentManager = parentFragmentManager, + view = view, + ), + owner = this, + view = view, + ) } override fun onResume() { @@ -78,13 +92,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) showToolbar(getString(R.string.preferences_addons)) } - override fun onStart() { - super.onStart() - findPreviousDialogFragment()?.let { dialog -> - dialog.onPositiveButtonClicked = onPositiveButtonClicked - } - } - override fun onDestroyView() { super.onDestroyView() // letting go of the resources to avoid memory leak. @@ -95,7 +102,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) private fun bindRecyclerView() { val managementView = AddonsManagementView( navController = findNavController(), - showPermissionDialog = ::showPermissionDialog, + onInstallButtonClicked = ::installAddon, ) val recyclerView = binding?.addOnsList @@ -107,7 +114,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) val allowCache = args.installAddonId == null || installExternalAddonComplete lifecycleScope.launch(IO) { try { - val addons = requireContext().components.addonManager.getAddons(allowCache = allowCache) + addons = requireContext().components.addonManager.getAddons(allowCache) // Add-ons that should be excluded in Mozilla Online builds val excludedAddonIDs = if (Config.channel.isMozillaOnline && !BuildConfig.MOZILLA_ONLINE_ADDON_EXCLUSIONS.isNullOrEmpty() @@ -167,7 +174,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) if (addonToInstall.isInstalled()) { showErrorSnackBar(getString(R.string.addon_already_installed)) } else { - showPermissionDialog(addonToInstall) + installAddon(addonToInstall) } } installExternalAddonComplete = true @@ -198,43 +205,11 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) ) } - private fun findPreviousDialogFragment(): PermissionsDialogFragment? { - return parentFragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment - } - - private fun hasExistingPermissionDialogFragment(): Boolean { - return findPreviousDialogFragment() != null - } - private fun hasExistingAddonInstallationDialogFragment(): Boolean { return parentFragmentManager.findFragmentByTag(INSTALLATION_DIALOG_FRAGMENT_TAG) as? AddonInstallationDialogFragment != null } - @VisibleForTesting - internal fun showPermissionDialog(addon: Addon) { - if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { - val dialog = PermissionsDialogFragment.newInstance( - addon = addon, - promptsStyling = PermissionsDialogFragment.PromptsStyling( - gravity = Gravity.BOTTOM, - shouldWidthMatchParent = true, - positiveButtonBackgroundColor = ThemeManager.resolveAttribute( - R.attr.accent, - requireContext(), - ), - positiveButtonTextColor = ThemeManager.resolveAttribute( - R.attr.textOnColorPrimary, - requireContext(), - ), - positiveButtonRadius = (resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat(), - ), - onPositiveButtonClicked = onPositiveButtonClicked, - ) - dialog.show(parentFragmentManager, PERMISSIONS_DIALOG_FRAGMENT_TAG) - } - } - private fun showInstallationDialog(addon: Addon) { if (!isInstallationInProgress && !hasExistingAddonInstallationDialogFragment()) { val context = requireContext() @@ -282,16 +257,8 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) } } - private val onPositiveButtonClicked: ((Addon) -> Unit) = { addon -> - binding?.addonProgressOverlay?.overlayCardView?.visibility = View.VISIBLE - - if (requireContext().settings().accessibilityServicesEnabled) { - binding?.let { announceForAccessibility(it.addonProgressOverlay.addOnsOverlayText.text) } - } - - isInstallationInProgress = true - - val installOperation = requireContext().components.addonManager.installAddon( + internal fun installAddon(addon: Addon) { + requireContext().components.addonManager.installAddon( addon, onSuccess = { runIfFragmentIsAttached { @@ -321,39 +288,9 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) } }, ) - - binding?.addonProgressOverlay?.cancelButton?.setOnClickListener { - lifecycleScope.launch(Dispatchers.Main) { - val safeBinding = binding - // Hide the installation progress overlay once cancellation is successful. - if (installOperation.cancel().await()) { - safeBinding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE - } - } - } - } - - private fun announceForAccessibility(announcementText: CharSequence) { - val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - AccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT) - } else { - @Suppress("DEPRECATION") - AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT) - } - - binding?.addonProgressOverlay?.overlayCardView?.onInitializeAccessibilityEvent(event) - event.text.add(announcementText) - event.contentDescription = null - binding?.addonProgressOverlay?.overlayCardView?.let { - it.parent?.requestSendAccessibilityEvent( - it, - event, - ) - } } companion object { - private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" private const val INSTALLATION_DIALOG_FRAGMENT_TAG = "ADDONS_INSTALLATION_DIALOG_FRAGMENT" private const val BUNDLE_KEY_INSTALL_EXTERNAL_ADDON_COMPLETE = "INSTALL_EXTERNAL_ADDON_COMPLETE" } diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementView.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementView.kt index 593beb49af..a58fa95965 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementView.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementView.kt @@ -15,7 +15,7 @@ import org.mozilla.fenix.ext.navigateSafe */ class AddonsManagementView( private val navController: NavController, - private val showPermissionDialog: (Addon) -> Unit, + private val onInstallButtonClicked: (Addon) -> Unit, ) : AddonsManagerAdapterDelegate { override fun onAddonItemClicked(addon: Addon) { @@ -27,7 +27,7 @@ class AddonsManagementView( } override fun onInstallAddonButtonClicked(addon: Addon) { - showPermissionDialog(addon) + onInstallButtonClicked(addon) } override fun onNotYetSupportedSectionClicked(unsupportedAddons: List) { diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index e77a68d33d..c95d3b1a76 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -59,6 +59,7 @@ import mozilla.components.concept.engine.permission.SitePermissions import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaWebChannelFeature +import mozilla.components.feature.addons.Addon import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.contextmenu.ContextMenuCandidate import mozilla.components.feature.contextmenu.ContextMenuFeature @@ -134,6 +135,7 @@ import org.mozilla.fenix.downloads.ThirdPartyDownloadDialog import org.mozilla.fenix.ext.accessibilityManager import org.mozilla.fenix.ext.breadcrumb import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.getFenixAddons import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.nav @@ -142,6 +144,7 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.secure import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.extension.WebExtensionPromptFeature import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.SharedViewModel import org.mozilla.fenix.perf.MarkersFragmentLifecycleCallbacks @@ -203,6 +206,7 @@ abstract class BaseBrowserFragment : private val fullScreenFeature = ViewBoundFeatureWrapper() private val swipeRefreshFeature = ViewBoundFeatureWrapper() private val webchannelIntegration = ViewBoundFeatureWrapper() + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() private val sitePermissionWifiIntegration = ViewBoundFeatureWrapper() private val secureWindowFeature = ViewBoundFeatureWrapper() @@ -894,6 +898,17 @@ abstract class BaseBrowserFragment : view = view, ) + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = requireComponents.core.store, + provideAddons = ::provideAddons, + context = requireContext(), + fragmentManager = parentFragmentManager, + view = view, + ), + owner = this, + view = view, + ) initializeEngineView(toolbarHeight) } @@ -1605,4 +1620,13 @@ abstract class BaseBrowserFragment : return isValidStatus && isSameTab } + + private suspend fun provideAddons(): List { + return withContext(IO) { + // We deactivated the cache to get the most up-to-date list of add-ons to match against. + // as this will be used to install add-ons from AMO. + val addons = requireContext().components.addonManager.getFenixAddons(allowCache = false) + addons + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/ext/AddonManager.kt b/app/src/main/java/org/mozilla/fenix/ext/AddonManager.kt new file mode 100644 index 0000000000..e45e551651 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/ext/AddonManager.kt @@ -0,0 +1,26 @@ +/* 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.ext + +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.AddonManager +import org.mozilla.fenix.BuildConfig +import org.mozilla.fenix.Config + +/** + * Returns the list of all installed and recommended add-ons filters out all the + * add-ons on [BuildConfig.MOZILLA_ONLINE_ADDON_EXCLUSIONS]. + */ +suspend fun AddonManager.getFenixAddons(allowCache: Boolean = true): List { + val addons = getAddons(allowCache = allowCache) + val excludedAddonIDs = if (Config.channel.isMozillaOnline && + !BuildConfig.MOZILLA_ONLINE_ADDON_EXCLUSIONS.isNullOrEmpty() + ) { + BuildConfig.MOZILLA_ONLINE_ADDON_EXCLUSIONS.toList() + } else { + emptyList() + } + return addons.filterNot { it.id in excludedAddonIDs } +} diff --git a/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt b/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt new file mode 100644 index 0000000000..fb0b00a23c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/extension/WebExtensionPromptFeature.kt @@ -0,0 +1,171 @@ +/* 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 android.content.Context +import android.view.Gravity +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +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.feature.addons.Addon +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.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.addons.showSnackBar +import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.theme.ThemeManager + +/** + * Feature implementation for handling [WebExtensionPromptRequest] and showing the respective UI. + */ +class WebExtensionPromptFeature( + private val store: BrowserStore, + private val provideAddons: suspend () -> List, + private val context: Context, + private val view: View, + private val fragmentManager: FragmentManager, +) : LifecycleAwareFeature { + + /** + * Whether or not an add-on installation is in progress. + */ + private var isInstallationInProgress = false + private var scope: CoroutineScope? = null + + /** + * Starts observing the selected session to listen for window requests + * and opens / closes tabs as needed. + */ + override fun start() { + scope = store.flowScoped { flow -> + flow.mapNotNull { state -> + state.webExtensionPromptRequest + }.ifChanged().collect { promptRequest -> + if (promptRequest is WebExtensionPromptRequest.Permissions && !hasExistingPermissionDialogFragment()) { + val addon = provideAddons().find { addon -> + addon.id == promptRequest.extension.id + } + + // If the add-on is not found, it is already installed because the install process can only + // be triggered for add-ons "known" by Fenix (the add-on is either part of the official list + // of supported extensions OR part of the user custom AMO collection). + if (addon == null) { + promptRequest.onConfirm(false) + consumePromptRequest() + showSnackBar( + view, + context.getString(R.string.addon_already_installed), + FenixSnackbar.LENGTH_LONG, + ) + } else { + showPermissionDialog( + addon, + promptRequest, + ) + } + } + } + } + tryToReAttachButtonHandlersToPreviousDialog() + } + + /** + * Stops observing the selected session for incoming window requests. + */ + override fun stop() { + scope?.cancel() + } + + @VisibleForTesting + internal fun showPermissionDialog( + addon: Addon, + promptRequest: WebExtensionPromptRequest.Permissions, + ) { + if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { + val dialog = PermissionsDialogFragment.newInstance( + addon = addon, + promptsStyling = PermissionsDialogFragment.PromptsStyling( + gravity = Gravity.BOTTOM, + shouldWidthMatchParent = true, + positiveButtonBackgroundColor = ThemeManager.resolveAttribute( + R.attr.accent, + context, + ), + positiveButtonTextColor = ThemeManager.resolveAttribute( + R.attr.textOnColorPrimary, + context, + ), + positiveButtonRadius = + (context.resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat(), + ), + onPositiveButtonClicked = { + handleApprovedPermissions(promptRequest) + }, + onNegativeButtonClicked = { + handleDeniedPermissions(promptRequest) + }, + ) + dialog.show( + fragmentManager, + PERMISSIONS_DIALOG_FRAGMENT_TAG, + ) + } + } + + private fun tryToReAttachButtonHandlersToPreviousDialog() { + findPreviousDialogFragment()?.let { dialog -> + dialog.onPositiveButtonClicked = { addon -> + store.state.webExtensionPromptRequest?.let { promptRequest -> + if (addon.id == promptRequest.extension.id && + promptRequest is WebExtensionPromptRequest.Permissions + ) { + handleApprovedPermissions(promptRequest) + } + } + } + dialog.onNegativeButtonClicked = { + store.state.webExtensionPromptRequest?.let { promptRequest -> + if (promptRequest is WebExtensionPromptRequest.Permissions) { + handleDeniedPermissions(promptRequest) + } + } + } + } + } + + private fun handleDeniedPermissions(promptRequest: WebExtensionPromptRequest.Permissions) { + promptRequest.onConfirm(false) + consumePromptRequest() + } + + private fun handleApprovedPermissions(promptRequest: WebExtensionPromptRequest.Permissions) { + promptRequest.onConfirm(true) + consumePromptRequest() + } + + private fun consumePromptRequest() { + store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction) + } + + private fun hasExistingPermissionDialogFragment(): Boolean { + return findPreviousDialogFragment() != null + } + + private fun findPreviousDialogFragment(): PermissionsDialogFragment? { + return fragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment + } + + companion object { + private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" + } +} 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 d35c078e86..666dc9b545 100644 --- a/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt +++ b/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementFragmentTest.kt @@ -32,7 +32,6 @@ class AddonsManagementFragmentTest { every { fragment.context } returns context every { fragment.view } returns view every { fragment.showErrorSnackBar(any()) } returns Unit - every { fragment.showPermissionDialog(any()) } returns Unit every { fragment.getString(R.string.addon_not_supported_error) } returns addonNotSupportedErrorMessage every { fragment.getString(R.string.addon_already_installed) } returns addonAlreadyInstalledErrorMessage } @@ -57,14 +56,4 @@ class AddonsManagementFragmentTest { fragment.installExternalAddon(supportedAddons, "d1") verify { fragment.showErrorSnackBar(addonAlreadyInstalledErrorMessage) } } - - @Test - fun `GIVEN add-on is installed from external source WHEN supported and not installed THEN start installation`() { - val addon1 = Addon("1", downloadId = "d1", installedState = mockk()) - val addon2 = Addon("2", downloadId = "d2") - val supportedAddons = listOf(addon1, addon2) - - fragment.installExternalAddon(supportedAddons, "d2") - verify { fragment.showPermissionDialog(addon2) } - } }