Bug 1825126 - Implement onInstallPermissionRequest in WebExtensionSupport.

Co-authored-by: William Durand <will+git@drnd.me>
Co-authored-by: arturo mejia <arturomejiamarmol@gmail.com>
fenix/116.0
William Durand 1 year ago committed by mergify[bot]
parent b3f1997fa2
commit bea6f0b0da

@ -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())

@ -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<WebExtensionPromptFeature>()
private lateinit var addons: List<Addon>
/**
* 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"
}

@ -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<Addon>) {

@ -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<FullScreenFeature>()
private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
private val webchannelIntegration = ViewBoundFeatureWrapper<FxaWebChannelFeature>()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private val sitePermissionWifiIntegration =
ViewBoundFeatureWrapper<SitePermissionsWifiIntegration>()
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
@ -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<Addon> {
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
}
}
}

@ -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<Addon> {
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<String>()
}
return addons.filterNot { it.id in excludedAddonIDs }
}

@ -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<Addon>,
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"
}
}

@ -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) }
}
}

Loading…
Cancel
Save