Bug 1847088 - Show a notification in the Fenix add-on manager when the extensions process spawning is disabled

This commit is contained in:
William Durand 2023-10-03 09:59:27 +02:00 committed by mergify[bot]
parent 6bdb2b5b39
commit 1883946a8b
23 changed files with 452 additions and 106 deletions

View file

@ -73,10 +73,24 @@ object InitAction : BrowserAction()
object RestoreCompleteAction : BrowserAction() object RestoreCompleteAction : BrowserAction()
/** /**
* [BrowserAction] implementation for updating state related to whether the extensions process * [BrowserAction] implementations to react to extensions process events.
* spawning has been disabled and a popup is necessary.
*/ */
data class ExtensionProcessDisabledPopupAction(val showPopup: Boolean) : BrowserAction() sealed class ExtensionsProcessAction : BrowserAction() {
/**
* [BrowserAction] to indicate when the crash prompt should be displayed to the user.
*/
data class ShowPromptAction(val show: Boolean) : ExtensionsProcessAction()
/**
* [BrowserAction] to indicate that the process has been re-enabled by the user.
*/
object EnabledAction : ExtensionsProcessAction()
/**
* [BrowserAction] to indicate that the process has been left disabled by the user.
*/
object DisabledAction : ExtensionsProcessAction()
}
/** /**
* [BrowserAction] implementations to react to system events. * [BrowserAction] implementations to react to system events.

View file

@ -11,6 +11,7 @@ import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.engine.middleware.CrashMiddleware import mozilla.components.browser.state.engine.middleware.CrashMiddleware
import mozilla.components.browser.state.engine.middleware.CreateEngineSessionMiddleware import mozilla.components.browser.state.engine.middleware.CreateEngineSessionMiddleware
import mozilla.components.browser.state.engine.middleware.EngineDelegateMiddleware import mozilla.components.browser.state.engine.middleware.EngineDelegateMiddleware
import mozilla.components.browser.state.engine.middleware.ExtensionsProcessMiddleware
import mozilla.components.browser.state.engine.middleware.LinkingMiddleware import mozilla.components.browser.state.engine.middleware.LinkingMiddleware
import mozilla.components.browser.state.engine.middleware.SuspendMiddleware import mozilla.components.browser.state.engine.middleware.SuspendMiddleware
import mozilla.components.browser.state.engine.middleware.TabsRemovedMiddleware import mozilla.components.browser.state.engine.middleware.TabsRemovedMiddleware
@ -48,6 +49,7 @@ object EngineMiddleware {
SuspendMiddleware(scope), SuspendMiddleware(scope),
WebExtensionMiddleware(), WebExtensionMiddleware(),
CrashMiddleware(), CrashMiddleware(),
ExtensionsProcessMiddleware(engine),
) + if (trimMemoryAutomatically) { ) + if (trimMemoryAutomatically) {
listOf(TrimMemoryMiddleware()) listOf(TrimMemoryMiddleware())
} else { } else {

View file

@ -0,0 +1,45 @@
/* 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.browser.state.engine.middleware
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.concept.engine.Engine
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
/**
* [Middleware] implementation responsible for enabling and disabling the extensions process (spawning).
*
* @property engine An [Engine] instance used for handling extension process spawning.
*/
internal class ExtensionsProcessMiddleware(
private val engine: Engine,
) : Middleware<BrowserState, BrowserAction> {
override fun invoke(
context: MiddlewareContext<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
// Pre process actions
next(action)
// Post process actions
when (action) {
is ExtensionsProcessAction.EnabledAction -> {
engine.enableExtensionProcessSpawning()
}
is ExtensionsProcessAction.DisabledAction -> {
engine.disableExtensionProcessSpawning()
}
else -> {
// no-op
}
}
}
}

View file

@ -15,7 +15,7 @@ import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.DebugAction import mozilla.components.browser.state.action.DebugAction
import mozilla.components.browser.state.action.DownloadAction import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.ExtensionProcessDisabledPopupAction import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.InitAction import mozilla.components.browser.state.action.InitAction
import mozilla.components.browser.state.action.LastAccessAction import mozilla.components.browser.state.action.LastAccessAction
@ -76,7 +76,7 @@ internal object BrowserStateReducer {
is HistoryMetadataAction -> HistoryMetadataReducer.reduce(state, action) is HistoryMetadataAction -> HistoryMetadataReducer.reduce(state, action)
is DebugAction -> DebugReducer.reduce(state, action) is DebugAction -> DebugReducer.reduce(state, action)
is ShoppingProductAction -> ShoppingProductStateReducer.reduce(state, action) is ShoppingProductAction -> ShoppingProductStateReducer.reduce(state, action)
is ExtensionProcessDisabledPopupAction -> state.copy(showExtensionProcessDisabledPopup = action.showPopup) is ExtensionsProcessAction -> ExtensionsProcessStateReducer.reduce(state, action)
} }
} }
} }

View file

@ -0,0 +1,17 @@
/* 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.browser.state.reducer
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.state.BrowserState
internal object ExtensionsProcessStateReducer {
fun reduce(state: BrowserState, action: ExtensionsProcessAction): BrowserState = when (action) {
is ExtensionsProcessAction.ShowPromptAction -> state.copy(showExtensionsProcessDisabledPrompt = action.show)
is ExtensionsProcessAction.DisabledAction -> state.copy(extensionsProcessDisabled = true)
is ExtensionsProcessAction.EnabledAction -> state.copy(extensionsProcessDisabled = false)
}
}

View file

@ -49,5 +49,6 @@ data class BrowserState(
val undoHistory: UndoHistoryState = UndoHistoryState(), val undoHistory: UndoHistoryState = UndoHistoryState(),
val restoreComplete: Boolean = false, val restoreComplete: Boolean = false,
val locale: Locale? = null, val locale: Locale? = null,
val showExtensionProcessDisabledPopup: Boolean = false, val showExtensionsProcessDisabledPrompt: Boolean = false,
val extensionsProcessDisabled: Boolean = false,
) : State ) : State

View file

@ -0,0 +1,44 @@
/* 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.browser.state.engine.middleware
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.support.test.ext.joinBlocking
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
class ExtensionsProcessMiddlewareTest {
private lateinit var engine: Engine
private lateinit var store: BrowserStore
@Before
fun setUp() {
engine = Mockito.mock()
store = BrowserStore(
middleware = listOf(ExtensionsProcessMiddleware(engine)),
initialState = BrowserState(),
)
}
@Test
fun `WHEN EnabledAction is dispatched THEN enable the process spawning`() {
store.dispatch(ExtensionsProcessAction.EnabledAction).joinBlocking()
Mockito.verify(engine).enableExtensionProcessSpawning()
Mockito.verify(engine, Mockito.never()).disableExtensionProcessSpawning()
}
@Test
fun `WHEN DisabledAction is dispatched THEN disable the process spawning`() {
store.dispatch(ExtensionsProcessAction.DisabledAction).joinBlocking()
Mockito.verify(engine).disableExtensionProcessSpawning()
Mockito.verify(engine, Mockito.never()).enableExtensionProcessSpawning()
}
}

View file

@ -0,0 +1,41 @@
/* 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.browser.state.reducer
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.state.BrowserState
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExtensionsProcessStateReducerTest {
@Test
fun `GIVEN ShowPromptAction THEN showExtensionsProcessDisabledPrompt is updated`() {
var state = BrowserState()
assertFalse(state.showExtensionsProcessDisabledPrompt)
state = ExtensionsProcessStateReducer.reduce(state, ExtensionsProcessAction.ShowPromptAction(show = true))
assertTrue(state.showExtensionsProcessDisabledPrompt)
state = ExtensionsProcessStateReducer.reduce(state, ExtensionsProcessAction.ShowPromptAction(show = false))
assertFalse(state.showExtensionsProcessDisabledPrompt)
}
@Test
fun `GIVEN EnabledAction THEN extensionsProcessDisabled is set to false`() {
var state = BrowserState(extensionsProcessDisabled = true)
state = ExtensionsProcessStateReducer.reduce(state, ExtensionsProcessAction.EnabledAction)
assertFalse(state.extensionsProcessDisabled)
}
@Test
fun `GIVEN DisabledAction THEN extensionsProcessDisabled is set to true`() {
var state = BrowserState(extensionsProcessDisabled = false)
state = ExtensionsProcessStateReducer.reduce(state, ExtensionsProcessAction.DisabledAction)
assertTrue(state.extensionsProcessDisabled)
}
}

View file

@ -29,11 +29,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.AddonsProvider import mozilla.components.feature.addons.AddonsProvider
import mozilla.components.feature.addons.R import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.ui.CustomViewHolder.AddonViewHolder import mozilla.components.feature.addons.ui.CustomViewHolder.AddonViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.FooterViewHolder import mozilla.components.feature.addons.ui.CustomViewHolder.FooterViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.HeaderViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.SectionViewHolder import mozilla.components.feature.addons.ui.CustomViewHolder.SectionViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.UnsupportedSectionViewHolder import mozilla.components.feature.addons.ui.CustomViewHolder.UnsupportedSectionViewHolder
import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.log.logger.Logger
@ -47,6 +50,7 @@ private const val VIEW_HOLDER_TYPE_SECTION = 0
private const val VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION = 1 private const val VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION = 1
private const val VIEW_HOLDER_TYPE_ADDON = 2 private const val VIEW_HOLDER_TYPE_ADDON = 2
private const val VIEW_HOLDER_TYPE_FOOTER = 3 private const val VIEW_HOLDER_TYPE_FOOTER = 3
private const val VIEW_HOLDER_TYPE_HEADER = 4
/** /**
* An adapter for displaying add-on items. This will display information related to the state of * An adapter for displaying add-on items. This will display information related to the state of
@ -66,6 +70,7 @@ class AddonsManagerAdapter(
addons: List<Addon>, addons: List<Addon>,
private val style: Style? = null, private val style: Style? = null,
private val excludedAddonIDs: List<String> = emptyList(), private val excludedAddonIDs: List<String> = emptyList(),
private val store: BrowserStore,
) : ListAdapter<Any, CustomViewHolder>(DifferCallback) { ) : ListAdapter<Any, CustomViewHolder>(DifferCallback) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val logger = Logger("AddonsManagerAdapter") private val logger = Logger("AddonsManagerAdapter")
@ -88,6 +93,7 @@ class AddonsManagerAdapter(
VIEW_HOLDER_TYPE_SECTION -> createSectionViewHolder(parent) VIEW_HOLDER_TYPE_SECTION -> createSectionViewHolder(parent)
VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION -> createUnsupportedSectionViewHolder(parent) VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION -> createUnsupportedSectionViewHolder(parent)
VIEW_HOLDER_TYPE_FOOTER -> createFooterSectionViewHolder(parent) VIEW_HOLDER_TYPE_FOOTER -> createFooterSectionViewHolder(parent)
VIEW_HOLDER_TYPE_HEADER -> createHeaderSectionViewHolder(parent)
else -> throw IllegalArgumentException("Unrecognized viewType") else -> throw IllegalArgumentException("Unrecognized viewType")
} }
} }
@ -112,6 +118,19 @@ class AddonsManagerAdapter(
return FooterViewHolder(view) return FooterViewHolder(view)
} }
private fun createHeaderSectionViewHolder(parent: ViewGroup): CustomViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(
R.layout.mozac_feature_addons_header_section_item,
parent,
false,
)
val restartButton = view.findViewById<TextView>(R.id.restart_button)
return HeaderViewHolder(view, restartButton)
}
private fun createUnsupportedSectionViewHolder(parent: ViewGroup): CustomViewHolder { private fun createUnsupportedSectionViewHolder(parent: ViewGroup): CustomViewHolder {
val context = parent.context val context = parent.context
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
@ -159,6 +178,7 @@ class AddonsManagerAdapter(
is Section -> VIEW_HOLDER_TYPE_SECTION is Section -> VIEW_HOLDER_TYPE_SECTION
is NotYetSupportedSection -> VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION is NotYetSupportedSection -> VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION
is FooterSection -> VIEW_HOLDER_TYPE_FOOTER is FooterSection -> VIEW_HOLDER_TYPE_FOOTER
is HeaderSection -> VIEW_HOLDER_TYPE_HEADER
else -> throw IllegalArgumentException("items[position] has unrecognized type") else -> throw IllegalArgumentException("items[position] has unrecognized type")
} }
} }
@ -174,6 +194,7 @@ class AddonsManagerAdapter(
item as NotYetSupportedSection, item as NotYetSupportedSection,
) )
is FooterViewHolder -> bindFooterButton(holder) is FooterViewHolder -> bindFooterButton(holder)
is HeaderViewHolder -> bindHeaderButton(holder)
} }
} }
@ -213,14 +234,20 @@ class AddonsManagerAdapter(
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindFooterButton( internal fun bindFooterButton(holder: FooterViewHolder) {
holder: FooterViewHolder,
) {
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
addonsManagerDelegate.onFindMoreAddonsButtonClicked() addonsManagerDelegate.onFindMoreAddonsButtonClicked()
} }
} }
internal fun bindHeaderButton(holder: HeaderViewHolder) {
holder.restartButton.setOnClickListener {
store.dispatch(ExtensionsProcessAction.EnabledAction)
// Remove the notification.
submitList(currentList.filter { item: Any -> item != HeaderSection })
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindAddon( internal fun bindAddon(
holder: AddonViewHolder, holder: AddonViewHolder,
@ -372,6 +399,12 @@ class AddonsManagerAdapter(
} }
} }
// Calls are safe, except in tests since the store is mocked in most cases.
@Suppress("UNNECESSARY_SAFE_CALL")
if (store?.state?.extensionsProcessDisabled == true) {
itemsWithSections.add(HeaderSection)
}
// Add installed section and addons if available // Add installed section and addons if available
if (installedAddons.isNotEmpty()) { if (installedAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_enabled, false)) itemsWithSections.add(Section(R.string.mozac_feature_addons_enabled, false))
@ -414,6 +447,9 @@ class AddonsManagerAdapter(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal object FooterSection internal object FooterSection
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal object HeaderSection
/** /**
* Allows to customize how items should look like. * Allows to customize how items should look like.
*/ */

View file

@ -49,6 +49,14 @@ sealed class CustomViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val statusErrorView: View, val statusErrorView: View,
) : CustomViewHolder(view) ) : CustomViewHolder(view)
/**
* A view holder for displaying a section above the list of add-ons.
*/
class HeaderViewHolder(
view: View,
val restartButton: TextView,
) : CustomViewHolder(view)
/** /**
* A view holder for displaying a section below the list of add-ons. * A view holder for displaying a section below the list of add-ons.
*/ */

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<LinearLayout
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:padding="16dp"
android:orientation="vertical"
tools:ignore="RtlSymmetry">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/warning_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="center"
app:srcCompat="@drawable/mozac_ic_warning_fill_24"
app:tint="?android:attr/textColorLink"/>
<TextView
android:text="@string/mozac_feature_addons_manager_notification_title_text"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:paddingStart="12dp"
android:paddingEnd="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<TextView
android:text="@string/mozac_feature_addons_manager_notification_content_text"
android:textColor="?android:attr/textColorPrimary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="16dp"
android:paddingStart="36dp"/>
<Button
android:id="@+id/restart_button"
style="@style/Widget.AppCompat.Button.Borderless.Colored"
android:text="@string/mozac_feature_addons_manager_notification_restart_button"
android:textColor="?android:attr/textColorLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingVertical="8dp"/>
</LinearLayout>

View file

@ -145,6 +145,12 @@
<string name="mozac_feature_addons_addons">Add-ons</string> <string name="mozac_feature_addons_addons">Add-ons</string>
<!-- Label for add-ons sub menu item for add-ons manager--> <!-- Label for add-ons sub menu item for add-ons manager-->
<string name="mozac_feature_addons_addons_manager">Add-ons Manager</string> <string name="mozac_feature_addons_addons_manager">Add-ons Manager</string>
<!-- The title of the "crash" notification in the add-ons manager -->
<string name="mozac_feature_addons_manager_notification_title_text">Add-ons are temporarily disabled</string>
<!-- The content of the "crash" notification in the add-ons manager -->
<string name="mozac_feature_addons_manager_notification_content_text">One or more add-ons stopped working, making your system unstable.</string>
<!-- Button to re-enable the add-ons in the "crash" notification -->
<string name="mozac_feature_addons_manager_notification_restart_button">Restart add-ons</string>
<!-- Button in the add-ons manager that opens AMO in a tab --> <!-- Button in the add-ons manager that opens AMO in a tab -->
<string name="mozac_feature_addons_find_more_addons_button_text">Find more add-ons</string> <string name="mozac_feature_addons_find_more_addons_button_text">Find more add-ons</string>
<!-- The label of the allow button, this will be shown to the user when an add-on needs new permissions, with the button the user will indicate that they want to accept the new permissions and update the add-on--> <!-- The label of the allow button, this will be shown to the user when an add-on needs new permissions, with the button the user will indicate that they want to accept the new permissions and update the add-on-->

View file

@ -15,6 +15,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.amo.AMOAddonsProvider import mozilla.components.feature.addons.amo.AMOAddonsProvider
@ -51,6 +53,7 @@ class AddonsManagerAdapterTest {
@get:Rule @get:Rule
val coroutinesTestRule = MainCoroutineRule() val coroutinesTestRule = MainCoroutineRule()
private val scope = coroutinesTestRule.scope private val scope = coroutinesTestRule.scope
private val dispatcher = coroutinesTestRule.testDispatcher
// We must pass these variables to `bindAddon()` because looking up the version name // We must pass these variables to `bindAddon()` because looking up the version name
// requires package info that we do not have in the test context. // requires package info that we do not have in the test context.
@ -64,7 +67,7 @@ class AddonsManagerAdapterTest {
@Test @Test
fun `createListWithSections`() { fun `createListWithSections`() {
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList()) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), mock(), emptyList(), mock())
val installedAddon: Addon = mock() val installedAddon: Addon = mock()
val recommendedAddon: Addon = mock() val recommendedAddon: Addon = mock()
@ -130,7 +133,7 @@ class AddonsManagerAdapterTest {
val addon = mock<Addon>() val addon = mock<Addon>()
val mockedImageView = spy(ImageView(testContext)) val mockedImageView = spy(ImageView(testContext))
val mockedAddonsProvider = mock<AMOAddonsProvider>() val mockedAddonsProvider = mock<AMOAddonsProvider>()
val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList()) val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList(), mock(), emptyList(), mock())
whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).then { whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).then {
throw IOException("Request failed") throw IOException("Request failed")
} }
@ -149,7 +152,7 @@ class AddonsManagerAdapterTest {
val bitmap = mock<Bitmap>() val bitmap = mock<Bitmap>()
val mockedImageView = spy(ImageView(testContext)) val mockedImageView = spy(ImageView(testContext))
val mockedAddonsProvider = mock<AMOAddonsProvider>() val mockedAddonsProvider = mock<AMOAddonsProvider>()
val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList()) val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList(), mock(), emptyList(), mock())
whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenReturn(bitmap) whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenReturn(bitmap)
adapter.fetchIcon(addon, mockedImageView, scope).join() adapter.fetchIcon(addon, mockedImageView, scope).join()
@ -163,7 +166,8 @@ class AddonsManagerAdapterTest {
val bitmap = mock<Bitmap>() val bitmap = mock<Bitmap>()
val mockedImageView = spy(ImageView(testContext)) val mockedImageView = spy(ImageView(testContext))
val mockedAddonsProvider = mock<AMOAddonsProvider>() val mockedAddonsProvider = mock<AMOAddonsProvider>()
val adapter = spy(AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList())) val adapter =
spy(AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList(), mock(), emptyList(), mock()))
whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenAnswer { whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenAnswer {
runBlocking { runBlocking {
delay(1000) delay(1000)
@ -185,7 +189,7 @@ class AddonsManagerAdapterTest {
whenever(addon.installedState).thenReturn(installedState) whenever(addon.installedState).thenReturn(installedState)
val mockedImageView = spy(ImageView(testContext)) val mockedImageView = spy(ImageView(testContext))
val mockedAddonsProvider = mock<AMOAddonsProvider>() val mockedAddonsProvider = mock<AMOAddonsProvider>()
val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList()) val adapter = AddonsManagerAdapter(mockedAddonsProvider, mock(), emptyList(), mock(), emptyList(), mock())
val captor = argumentCaptor<BitmapDrawable>() val captor = argumentCaptor<BitmapDrawable>()
whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenReturn(null) whenever(mockedAddonsProvider.getAddonIconBitmap(addon)).thenReturn(null)
@ -239,7 +243,7 @@ class AddonsManagerAdapterTest {
addonNameTextColor = android.R.color.transparent, addonNameTextColor = android.R.color.transparent,
addonSummaryTextColor = android.R.color.white, addonSummaryTextColor = android.R.color.white,
) )
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), style) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), style, emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -269,7 +273,7 @@ class AddonsManagerAdapterTest {
sectionsTextColor = android.R.color.black, sectionsTextColor = android.R.color.black,
sectionsTypeFace = mock(), sectionsTypeFace = mock(),
) )
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style, emptyList(), mock())
adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position) adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position)
@ -292,7 +296,7 @@ class AddonsManagerAdapterTest {
sectionsTextColor = android.R.color.black, sectionsTextColor = android.R.color.black,
sectionsTypeFace = mock(), sectionsTypeFace = mock(),
) )
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style, emptyList(), mock())
adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position) adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position)
@ -312,7 +316,7 @@ class AddonsManagerAdapterTest {
sectionsTextColor = android.R.color.black, sectionsTextColor = android.R.color.black,
sectionsTypeFace = mock(), sectionsTypeFace = mock(),
) )
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style, emptyList(), mock())
adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position) adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position)
@ -333,7 +337,7 @@ class AddonsManagerAdapterTest {
sectionsTypeFace = mock(), sectionsTypeFace = mock(),
visibleDividers = false, visibleDividers = false,
) )
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style, emptyList(), mock())
adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position) adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position)
@ -361,7 +365,7 @@ class AddonsManagerAdapterTest {
dividerHeight = dividerHeight, dividerHeight = dividerHeight,
) )
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style) val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), style, emptyList(), mock())
adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position) adapter.bindSection(addonViewHolder, Section(R.string.mozac_feature_addons_disabled_section), position)
@ -399,7 +403,7 @@ class AddonsManagerAdapterTest {
createdAt = "", createdAt = "",
updatedAt = "", updatedAt = "",
) )
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
verify(titleView).setText("id") verify(titleView).setText("id")
@ -417,7 +421,7 @@ class AddonsManagerAdapterTest {
createdAt = "", createdAt = "",
updatedAt = "", updatedAt = "",
) )
val adapter = spy(AddonsManagerAdapter(mock(), mock(), listOf(addon))) val adapter = spy(AddonsManagerAdapter(mock(), mock(), listOf(addon), mock(), emptyList(), mock()))
assertEquals(addon, adapter.addonsMap[addon.id]) assertEquals(addon, adapter.addonsMap[addon.id])
@ -449,7 +453,8 @@ class AddonsManagerAdapterTest {
createdAt = "", createdAt = "",
updatedAt = "", updatedAt = "",
) )
val adapter = spy(AddonsManagerAdapter(mock(), mock(), listOf(addon1, addon2))) val adapter =
spy(AddonsManagerAdapter(mock(), mock(), listOf(addon1, addon2), mock(), emptyList(), mock()))
assertEquals(addon1, adapter.addonsMap[addon1.id]) assertEquals(addon1, adapter.addonsMap[addon1.id])
assertEquals(addon2, adapter.addonsMap[addon2.id]) assertEquals(addon2, adapter.addonsMap[addon2.id])
@ -530,7 +535,15 @@ class AddonsManagerAdapterTest {
), ),
) )
val unsupportedAddons = arrayListOf(unsupportedAddon, unsupportedAddonTwo) val unsupportedAddons = arrayListOf(unsupportedAddon, unsupportedAddonTwo)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, unsupportedAddons) val adapter = AddonsManagerAdapter(
mock(),
addonsManagerAdapterDelegate,
unsupportedAddons,
mock(),
emptyList(),
mock(),
)
adapter.bindNotYetSupportedSection(unsupportedSectionViewHolder, mock()) adapter.bindNotYetSupportedSection(unsupportedSectionViewHolder, mock())
verify(unsupportedSectionViewHolder.descriptionView).setText( verify(unsupportedSectionViewHolder.descriptionView).setText(
testContext.getString(R.string.mozac_feature_addons_unsupported_caption_plural, unsupportedAddons.size), testContext.getString(R.string.mozac_feature_addons_unsupported_caption_plural, unsupportedAddons.size),
@ -543,7 +556,8 @@ class AddonsManagerAdapterTest {
@Test @Test
fun bindFooterButton() { fun bindFooterButton() {
val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock() val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock()
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
val view = View(testContext) val view = View(testContext)
val viewHolder = CustomViewHolder.FooterViewHolder(view) val viewHolder = CustomViewHolder.FooterViewHolder(view)
adapter.bindFooterButton(viewHolder) adapter.bindFooterButton(viewHolder)
@ -552,11 +566,46 @@ class AddonsManagerAdapterTest {
verify(addonsManagerAdapterDelegate).onFindMoreAddonsButtonClicked() verify(addonsManagerAdapterDelegate).onFindMoreAddonsButtonClicked()
} }
@Test
fun bindHeaderButton() {
val store = BrowserStore(initialState = BrowserState(extensionsProcessDisabled = true))
val adapter = spy(AddonsManagerAdapter(mock(), mock(), emptyList(), mock(), emptyList(), store))
val restartButton = TextView(testContext)
val viewHolder = CustomViewHolder.HeaderViewHolder(View(testContext), restartButton)
adapter.bindHeaderButton(viewHolder)
assertEquals(1, adapter.currentList.size)
viewHolder.restartButton.performClick()
dispatcher.scheduler.advanceUntilIdle()
assertFalse(store.state.extensionsProcessDisabled)
verify(adapter).submitList(emptyList())
}
@Test
fun testNotificationShownWhenProcessIsDisabled() {
val store = BrowserStore(initialState = BrowserState(extensionsProcessDisabled = true))
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), mock(), emptyList(), store)
val itemsWithSections = adapter.createListWithSections(emptyList())
assertEquals(AddonsManagerAdapter.HeaderSection, itemsWithSections.first())
}
@Test
fun testNotificationNotShownWhenProcessIsEnabled() {
val store = BrowserStore(initialState = BrowserState(extensionsProcessDisabled = false))
val adapter = AddonsManagerAdapter(mock(), mock(), emptyList(), mock(), emptyList(), store)
val itemsWithSections = adapter.createListWithSections(emptyList())
assertTrue(itemsWithSections.isEmpty())
}
@Test @Test
fun testFindMoreAddonsButtonIsHidden() { fun testFindMoreAddonsButtonIsHidden() {
val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock() val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock()
whenever(addonsManagerAdapterDelegate.shouldShowFindMoreAddonsButton()).thenReturn(false) whenever(addonsManagerAdapterDelegate.shouldShowFindMoreAddonsButton()).thenReturn(false)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
val itemsWithSections = adapter.createListWithSections(emptyList()) val itemsWithSections = adapter.createListWithSections(emptyList())
assertTrue(itemsWithSections.isEmpty()) assertTrue(itemsWithSections.isEmpty())
@ -566,7 +615,7 @@ class AddonsManagerAdapterTest {
fun testFindMoreAddonsButtonIsVisible() { fun testFindMoreAddonsButtonIsVisible() {
val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock() val addonsManagerAdapterDelegate: AddonsManagerAdapterDelegate = mock()
whenever(addonsManagerAdapterDelegate.shouldShowFindMoreAddonsButton()).thenReturn(true) whenever(addonsManagerAdapterDelegate.shouldShowFindMoreAddonsButton()).thenReturn(true)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
val itemsWithSections = adapter.createListWithSections(emptyList()) val itemsWithSections = adapter.createListWithSections(emptyList())
assertFalse(itemsWithSections.isEmpty()) assertFalse(itemsWithSections.isEmpty())
@ -603,7 +652,7 @@ class AddonsManagerAdapterTest {
) )
val addonName = "some addon name" val addonName = "some addon name"
val addon = makeDisabledAddon(Addon.DisabledReason.BLOCKLISTED, addonName) val addon = makeDisabledAddon(Addon.DisabledReason.BLOCKLISTED, addonName)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -646,7 +695,7 @@ class AddonsManagerAdapterTest {
statusErrorView = statusErrorView, statusErrorView = statusErrorView,
) )
val addon = makeDisabledAddon(Addon.DisabledReason.BLOCKLISTED) val addon = makeDisabledAddon(Addon.DisabledReason.BLOCKLISTED)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -684,7 +733,7 @@ class AddonsManagerAdapterTest {
) )
val addonName = "some addon name" val addonName = "some addon name"
val addon = makeDisabledAddon(Addon.DisabledReason.NOT_CORRECTLY_SIGNED, addonName) val addon = makeDisabledAddon(Addon.DisabledReason.NOT_CORRECTLY_SIGNED, addonName)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -727,7 +776,7 @@ class AddonsManagerAdapterTest {
statusErrorView = statusErrorView, statusErrorView = statusErrorView,
) )
val addon = makeDisabledAddon(Addon.DisabledReason.NOT_CORRECTLY_SIGNED) val addon = makeDisabledAddon(Addon.DisabledReason.NOT_CORRECTLY_SIGNED)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -765,7 +814,7 @@ class AddonsManagerAdapterTest {
) )
val addonName = "some addon name" val addonName = "some addon name"
val addon = makeDisabledAddon(Addon.DisabledReason.INCOMPATIBLE, addonName) val addon = makeDisabledAddon(Addon.DisabledReason.INCOMPATIBLE, addonName)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)
@ -803,7 +852,7 @@ class AddonsManagerAdapterTest {
statusErrorView = statusErrorView, statusErrorView = statusErrorView,
) )
val addon = makeDisabledAddon(Addon.DisabledReason.INCOMPATIBLE) val addon = makeDisabledAddon(Addon.DisabledReason.INCOMPATIBLE)
val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList()) val adapter = AddonsManagerAdapter(mock(), addonsManagerAdapterDelegate, emptyList(), mock(), emptyList(), mock())
adapter.bindAddon(addonViewHolder, addon, appName, appVersion) adapter.bindAddon(addonViewHolder, addon, appName, appVersion)

View file

@ -12,34 +12,34 @@ import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature import mozilla.components.support.base.feature.LifecycleAwareFeature
/** /**
* Feature implementation that shows a popup when the extensions process spawning has been * Feature implementation that shows a prompt when the extensions process spawning has been
* disabled due to the restart threshold being met. * disabled due to the restart threshold being met.
* *
* @property store the application's [BrowserStore]. * @property store the application's [BrowserStore].
* @property onShowExtensionProcessDisabledPopup a callback invoked when the application should open a * @property onShowExtensionsProcessDisabledPrompt a callback invoked when the application should open a
* popup. * prompt.
*/ */
open class ExtensionProcessDisabledPopupObserver( open class ExtensionsProcessDisabledPromptObserver(
private val store: BrowserStore, private val store: BrowserStore,
private val onShowExtensionProcessDisabledPopup: () -> Unit = { }, private val onShowExtensionsProcessDisabledPrompt: () -> Unit = { },
) : LifecycleAwareFeature { ) : LifecycleAwareFeature {
private var popupScope: CoroutineScope? = null private var promptScope: CoroutineScope? = null
override fun start() { override fun start() {
popupScope = store.flowScoped { flow -> promptScope = store.flowScoped { flow ->
flow.distinctUntilChangedBy { it.showExtensionProcessDisabledPopup } flow.distinctUntilChangedBy { it.showExtensionsProcessDisabledPrompt }
.collect { state -> .collect { state ->
if (state.showExtensionProcessDisabledPopup) { if (state.showExtensionsProcessDisabledPrompt) {
// There should only be one active dialog to the user when the extensions // There should only be one active dialog to the user when the extensions
// process spawning is disabled. // process spawning is disabled.
onShowExtensionProcessDisabledPopup() onShowExtensionsProcessDisabledPrompt()
} }
} }
} }
} }
override fun stop() { override fun stop() {
popupScope?.cancel() promptScope?.cancel()
popupScope = null promptScope = null
} }
} }

View file

@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.action.CustomTabListAction import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.ExtensionProcessDisabledPopupAction import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.action.WebExtensionAction import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.browser.state.selector.allTabs import mozilla.components.browser.state.selector.allTabs
@ -333,7 +333,7 @@ object WebExtensionSupport {
} }
override fun onDisabledExtensionProcessSpawning() { override fun onDisabledExtensionProcessSpawning() {
store.dispatch(ExtensionProcessDisabledPopupAction(showPopup = true)) store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
} }
}, },
) )

View file

@ -889,7 +889,7 @@ class WebExtensionSupportTest {
val store = BrowserStore() val store = BrowserStore()
val engine: Engine = mock() val engine: Engine = mock()
assertFalse(store.state.showExtensionProcessDisabledPopup) assertFalse(store.state.showExtensionsProcessDisabledPrompt)
val delegateCaptor = argumentCaptor<WebExtensionDelegate>() val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
WebExtensionSupport.initialize(engine, store) WebExtensionSupport.initialize(engine, store)
@ -898,7 +898,7 @@ class WebExtensionSupportTest {
delegateCaptor.value.onDisabledExtensionProcessSpawning() delegateCaptor.value.onDisabledExtensionProcessSpawning()
store.waitUntilIdle() store.waitUntilIdle()
assertTrue(store.state.showExtensionProcessDisabledPopup) assertTrue(store.state.showExtensionsProcessDisabledPrompt)
} }
@Test @Test

View file

@ -96,6 +96,7 @@ class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate {
addonsManagerDelegate = this@AddonsFragment, addonsManagerDelegate = this@AddonsFragment,
addons = addons, addons = addons,
style = style, style = style,
store = context.components.store,
) )
recyclerView.adapter = adapter recyclerView.adapter = adapter
} else { } else {

View file

@ -91,7 +91,7 @@ import org.mozilla.fenix.GleanMetrics.StartOnHome
import org.mozilla.fenix.addons.AddonDetailsFragmentDirections import org.mozilla.fenix.addons.AddonDetailsFragmentDirections
import org.mozilla.fenix.addons.AddonPermissionsDetailsFragmentDirections import org.mozilla.fenix.addons.AddonPermissionsDetailsFragmentDirections
import org.mozilla.fenix.addons.AddonsManagementFragmentDirections import org.mozilla.fenix.addons.AddonsManagementFragmentDirections
import org.mozilla.fenix.addons.ExtensionProcessDisabledController import org.mozilla.fenix.addons.ExtensionsProcessDisabledController
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager
@ -194,8 +194,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
WebExtensionPopupObserver(components.core.store, ::openPopup) WebExtensionPopupObserver(components.core.store, ::openPopup)
} }
private val extensionProcessDisabledPopupObserver by lazy { private val extensionsProcessDisabledPromptObserver by lazy {
ExtensionProcessDisabledController(this@HomeActivity, components.core.store) ExtensionsProcessDisabledController(this@HomeActivity, components.core.store)
} }
private val serviceWorkerSupport by lazy { private val serviceWorkerSupport by lazy {
@ -347,7 +347,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
} }
supportActionBar?.hide() supportActionBar?.hide()
lifecycle.addObservers(webExtensionPopupObserver, extensionProcessDisabledPopupObserver, serviceWorkerSupport) lifecycle.addObservers(webExtensionPopupObserver, extensionsProcessDisabledPromptObserver, serviceWorkerSupport)
if (shouldAddToRecentsScreen(intent)) { if (shouldAddToRecentsScreen(intent)) {
intent.removeExtra(START_IN_RECENTS_SCREEN) intent.removeExtra(START_IN_RECENTS_SCREEN)

View file

@ -114,11 +114,12 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
runIfFragmentIsAttached { runIfFragmentIsAttached {
if (!shouldRefresh) { if (!shouldRefresh) {
adapter = AddonsManagerAdapter( adapter = AddonsManagerAdapter(
requireContext().components.addonsProvider, addonsProvider = requireContext().components.addonsProvider,
managementView, addonsManagerDelegate = managementView,
addons, addons = addons,
style = createAddonStyle(requireContext()), style = createAddonStyle(requireContext()),
excludedAddonIDs, excludedAddonIDs = excludedAddonIDs,
store = requireComponents.core.store,
) )
} }
binding?.addOnsProgressBar?.isVisible = false binding?.addOnsProgressBar?.isVisible = false

View file

@ -11,33 +11,28 @@ import android.widget.TextView
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import mozilla.components.browser.state.action.ExtensionProcessDisabledPopupAction import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.support.ktx.android.content.appName import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.webextensions.ExtensionProcessDisabledPopupObserver import mozilla.components.support.webextensions.ExtensionsProcessDisabledPromptObserver
import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
/** /**
* Controller for showing the user a dialog when the the extension process spawning has been disabled. * Controller for showing the user a dialog when the the extensions process spawning has been disabled.
* *
* @param context to show the AlertDialog * @param context to show the AlertDialog
* @param store The [BrowserStore] which holds the state for showing the dialog * @param store The [BrowserStore] which holds the state for showing the dialog
* @param engine An [Engine] instance used for handling extension process spawning.
* @param builder to use for creating the dialog which can be styled as needed * @param builder to use for creating the dialog which can be styled as needed
* @param appName to be added to the message. Optional and mainly relevant for testing * @param appName to be added to the message. Optional and mainly relevant for testing
*/ */
class ExtensionProcessDisabledController( class ExtensionsProcessDisabledController(
@UiContext context: Context, @UiContext context: Context,
store: BrowserStore, store: BrowserStore,
engine: Engine = context.components.core.engine,
builder: AlertDialog.Builder = AlertDialog.Builder(context), builder: AlertDialog.Builder = AlertDialog.Builder(context),
appName: String = context.appName, appName: String = context.appName,
) : ExtensionProcessDisabledPopupObserver( ) : ExtensionsProcessDisabledPromptObserver(
store, store,
{ presentDialog(context, store, engine, builder, appName) }, { presentDialog(context, store, builder, appName) },
) { ) {
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner) super.onDestroy(owner)
@ -49,21 +44,18 @@ class ExtensionProcessDisabledController(
private var shouldCreateDialog: Boolean = true private var shouldCreateDialog: Boolean = true
/** /**
* Present a dialog to the user notifying of extension process spawning disabled and also asking * Present a dialog to the user notifying of extensions process spawning disabled and also asking
* whether they would like to continue trying or disable extensions. If the user chooses to retry, * whether they would like to continue trying or disable extensions. If the user chooses to retry,
* enable the extension process spawning with [Engine.enableExtensionProcessSpawning]. * enable the extensions process spawning. Otherwise, disable it.
* Otherwise, call [Engine.disableExtensionProcessSpawning].
* *
* @param context to show the AlertDialog * @param context to show the AlertDialog
* @param store The [BrowserStore] which holds the state for showing the dialog * @param store The [BrowserStore] which holds the state for showing the dialog
* @param engine An [Engine] instance used for handling extension process spawning
* @param builder to use for creating the dialog which can be styled as needed * @param builder to use for creating the dialog which can be styled as needed
* @param appName to be added to the message. Necessary to be added as a param for testing * @param appName to be added to the message. Necessary to be added as a param for testing
*/ */
private fun presentDialog( private fun presentDialog(
@UiContext context: Context, @UiContext context: Context,
store: BrowserStore, store: BrowserStore,
engine: Engine,
builder: AlertDialog.Builder, builder: AlertDialog.Builder,
appName: String, appName: String,
) { ) {
@ -78,15 +70,13 @@ class ExtensionProcessDisabledController(
layout?.apply { layout?.apply {
findViewById<TextView>(R.id.message)?.text = message findViewById<TextView>(R.id.message)?.text = message
findViewById<Button>(R.id.positive)?.setOnClickListener { findViewById<Button>(R.id.positive)?.setOnClickListener {
engine.enableExtensionProcessSpawning() store.dispatch(ExtensionsProcessAction.ShowPromptAction(false))
Addons.extensionsProcessUiRetry.add() store.dispatch(ExtensionsProcessAction.EnabledAction)
store.dispatch(ExtensionProcessDisabledPopupAction(false))
onDismissDialog?.invoke() onDismissDialog?.invoke()
} }
findViewById<Button>(R.id.negative)?.setOnClickListener { findViewById<Button>(R.id.negative)?.setOnClickListener {
engine.disableExtensionProcessSpawning() store.dispatch(ExtensionsProcessAction.ShowPromptAction(false))
Addons.extensionsProcessUiDisable.add() store.dispatch(ExtensionsProcessAction.DisabledAction)
store.dispatch(ExtensionProcessDisabledPopupAction(false))
onDismissDialog?.invoke() onDismissDialog?.invoke()
} }
} }

View file

@ -9,6 +9,7 @@ import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.DownloadAction import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.selector.findTabOrCustomTab
@ -23,6 +24,7 @@ import mozilla.components.support.base.log.logger.Logger
import mozilla.telemetry.glean.internal.TimerId import mozilla.telemetry.glean.internal.TimerId
import mozilla.telemetry.glean.private.NoExtras import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.Config import org.mozilla.fenix.Config
import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.Metrics import org.mozilla.fenix.GleanMetrics.Metrics
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
@ -136,6 +138,12 @@ class TelemetryMiddleware(
Metrics.hasOpenTabs.set(false) Metrics.hasOpenTabs.set(false)
} }
} }
is ExtensionsProcessAction.EnabledAction -> {
Addons.extensionsProcessUiRetry.add()
}
is ExtensionsProcessAction.DisabledAction -> {
Addons.extensionsProcessUiDisable.add()
}
else -> { else -> {
// no-op // no-op
} }

View file

@ -7,9 +7,8 @@ package org.mozilla.fenix.addons
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import mozilla.components.browser.state.action.ExtensionProcessDisabledPopupAction import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.support.test.argumentCaptor import mozilla.components.support.test.argumentCaptor
import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
@ -21,39 +20,41 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times import org.mockito.Mockito.times
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class ExtensionProcessDisabledControllerTest { class ExtensionsProcessDisabledControllerTest {
@get:Rule @get:Rule
val coroutinesTestRule = MainCoroutineRule() val coroutinesTestRule = MainCoroutineRule()
private val dispatcher = coroutinesTestRule.testDispatcher private val dispatcher = coroutinesTestRule.testDispatcher
@Test @Test
fun `WHEN showExtensionProcessDisabledPopup is true AND positive button clicked then enable extension process spawning`() { fun `WHEN showExtensionsProcessDisabledPrompt is true AND positive button clicked then enable extension process spawning`() {
val store = BrowserStore() val store = BrowserStore()
val engine: Engine = mock()
val dialog: AlertDialog = mock() val dialog: AlertDialog = mock()
val appName = "TestApp" val appName = "TestApp"
val builder: AlertDialog.Builder = mock() val builder: AlertDialog.Builder = mock()
val controller = ExtensionProcessDisabledController(testContext, store, engine, builder, appName) val controller = ExtensionsProcessDisabledController(testContext, store, builder, appName)
val buttonsContainerCaptor = argumentCaptor<View>() val buttonsContainerCaptor = argumentCaptor<View>()
controller.start() controller.start()
whenever(builder.show()).thenReturn(dialog) whenever(builder.show()).thenReturn(dialog)
assertFalse(store.state.showExtensionProcessDisabledPopup) assertFalse(store.state.showExtensionsProcessDisabledPrompt)
assertFalse(store.state.extensionsProcessDisabled)
store.dispatch(ExtensionProcessDisabledPopupAction(showPopup = true)) // Pretend the process has been disabled and we show the dialog.
store.dispatch(ExtensionsProcessAction.DisabledAction)
store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
store.waitUntilIdle() store.waitUntilIdle()
assertTrue(store.state.showExtensionProcessDisabledPopup) assertTrue(store.state.showExtensionsProcessDisabledPrompt)
assertTrue(store.state.extensionsProcessDisabled)
verify(builder).setView(buttonsContainerCaptor.capture()) verify(builder).setView(buttonsContainerCaptor.capture())
verify(builder).show() verify(builder).show()
@ -62,32 +63,34 @@ class ExtensionProcessDisabledControllerTest {
store.waitUntilIdle() store.waitUntilIdle()
verify(engine).enableExtensionProcessSpawning() assertFalse(store.state.showExtensionsProcessDisabledPrompt)
verify(engine, never()).disableExtensionProcessSpawning() assertFalse(store.state.extensionsProcessDisabled)
assertFalse(store.state.showExtensionProcessDisabledPopup)
verify(dialog).dismiss() verify(dialog).dismiss()
} }
@Test @Test
fun `WHEN showExtensionProcessDisabledPopup is true AND negative button clicked then dismiss without enabling extension process spawning`() { fun `WHEN showExtensionsProcessDisabledPrompt is true AND negative button clicked then dismiss without enabling extension process spawning`() {
val store = BrowserStore() val store = BrowserStore()
val engine: Engine = mock()
val appName = "TestApp" val appName = "TestApp"
val dialog: AlertDialog = mock() val dialog: AlertDialog = mock()
val builder: AlertDialog.Builder = mock() val builder: AlertDialog.Builder = mock()
val controller = ExtensionProcessDisabledController(testContext, store, engine, builder, appName) val controller = ExtensionsProcessDisabledController(testContext, store, builder, appName)
val buttonsContainerCaptor = argumentCaptor<View>() val buttonsContainerCaptor = argumentCaptor<View>()
controller.start() controller.start()
whenever(builder.show()).thenReturn(dialog) whenever(builder.show()).thenReturn(dialog)
assertFalse(store.state.showExtensionProcessDisabledPopup) assertFalse(store.state.showExtensionsProcessDisabledPrompt)
assertFalse(store.state.extensionsProcessDisabled)
store.dispatch(ExtensionProcessDisabledPopupAction(showPopup = true)) // Pretend the process has been disabled and we show the dialog.
store.dispatch(ExtensionsProcessAction.DisabledAction)
store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
store.waitUntilIdle() store.waitUntilIdle()
assertTrue(store.state.showExtensionProcessDisabledPopup) assertTrue(store.state.showExtensionsProcessDisabledPrompt)
assertTrue(store.state.extensionsProcessDisabled)
verify(builder).setView(buttonsContainerCaptor.capture()) verify(builder).setView(buttonsContainerCaptor.capture())
verify(builder).show() verify(builder).show()
@ -96,20 +99,18 @@ class ExtensionProcessDisabledControllerTest {
store.waitUntilIdle() store.waitUntilIdle()
assertFalse(store.state.showExtensionProcessDisabledPopup) assertFalse(store.state.showExtensionsProcessDisabledPrompt)
verify(engine, never()).enableExtensionProcessSpawning() assertTrue(store.state.extensionsProcessDisabled)
verify(engine).disableExtensionProcessSpawning()
verify(dialog).dismiss() verify(dialog).dismiss()
} }
@Test @Test
fun `WHEN dispatching the same event twice THEN the dialog should only be created once`() { fun `WHEN dispatching the same event twice THEN the dialog should only be created once`() {
val store = BrowserStore() val store = BrowserStore()
val engine: Engine = mock()
val appName = "TestApp" val appName = "TestApp"
val dialog: AlertDialog = mock() val dialog: AlertDialog = mock()
val builder: AlertDialog.Builder = mock() val builder: AlertDialog.Builder = mock()
val controller = ExtensionProcessDisabledController(testContext, store, engine, builder, appName) val controller = ExtensionsProcessDisabledController(testContext, store, builder, appName)
val buttonsContainerCaptor = argumentCaptor<View>() val buttonsContainerCaptor = argumentCaptor<View>()
controller.start() controller.start()
@ -117,12 +118,12 @@ class ExtensionProcessDisabledControllerTest {
whenever(builder.show()).thenReturn(dialog) whenever(builder.show()).thenReturn(dialog)
// First dispatch... // First dispatch...
store.dispatch(ExtensionProcessDisabledPopupAction(showPopup = true)) store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
store.waitUntilIdle() store.waitUntilIdle()
// Second dispatch... without having dismissed the dialog before! // Second dispatch... without having dismissed the dialog before!
store.dispatch(ExtensionProcessDisabledPopupAction(showPopup = true)) store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
store.waitUntilIdle() store.waitUntilIdle()

View file

@ -6,10 +6,13 @@ package org.mozilla.fenix.telemetry
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.ExtensionsProcessAction
import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.engine.EngineMiddleware import mozilla.components.browser.state.engine.EngineMiddleware
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
@ -17,6 +20,7 @@ import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.state.recover.RecoverableTab import mozilla.components.browser.state.state.recover.RecoverableTab
import mozilla.components.browser.state.state.recover.TabState import mozilla.components.browser.state.state.recover.TabState
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.service.glean.testing.GleanTestRule import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.base.android.Clock import mozilla.components.support.base.android.Clock
import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.ext.joinBlocking
@ -33,6 +37,7 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.Metrics import org.mozilla.fenix.GleanMetrics.Metrics
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
@ -78,8 +83,11 @@ class TelemetryMiddlewareTest {
searchState = searchState, searchState = searchState,
timerId = timerId, timerId = timerId,
) )
val engine: Engine = mockk()
every { engine.enableExtensionProcessSpawning() } just runs
every { engine.disableExtensionProcessSpawning() } just runs
store = BrowserStore( store = BrowserStore(
middleware = listOf(telemetryMiddleware) + EngineMiddleware.create(engine = mockk()), middleware = listOf(telemetryMiddleware) + EngineMiddleware.create(engine),
initialState = BrowserState(), initialState = BrowserState(),
) )
appStore = AppStore() appStore = AppStore()
@ -417,6 +425,28 @@ class TelemetryMiddlewareTest {
verify { metrics.track(Event.GrowthData.FirstUriLoadForDay) } verify { metrics.track(Event.GrowthData.FirstUriLoadForDay) }
} }
@Test
fun `WHEN EnabledAction is dispatched THEN enable the process spawning`() {
assertNull(Addons.extensionsProcessUiRetry.testGetValue())
assertNull(Addons.extensionsProcessUiDisable.testGetValue())
store.dispatch(ExtensionsProcessAction.EnabledAction).joinBlocking()
assertEquals(1, Addons.extensionsProcessUiRetry.testGetValue())
assertNull(Addons.extensionsProcessUiDisable.testGetValue())
}
@Test
fun `WHEN DisabledAction is dispatched THEN disable the process spawning`() {
assertNull(Addons.extensionsProcessUiRetry.testGetValue())
assertNull(Addons.extensionsProcessUiDisable.testGetValue())
store.dispatch(ExtensionsProcessAction.DisabledAction).joinBlocking()
assertEquals(1, Addons.extensionsProcessUiDisable.testGetValue())
assertNull(Addons.extensionsProcessUiRetry.testGetValue())
}
} }
internal class FakeClock : Clock.Delegate { internal class FakeClock : Clock.Delegate {