update extension prompt feature

This commit is contained in:
Fabian Freund
2026-04-28 14:27:07 +02:00
parent 27bac0d45a
commit bf94e1e0fe
7 changed files with 3312 additions and 2233 deletions
@@ -317,6 +317,12 @@ class BrowserScreen extends HookConsumerWidget {
);
},
);
useOnStreamChange(
addonService.openAddonSettingsStream,
onData: (addonId) async {
await AddonInternalSettingsRoute(addonId: addonId).push<void>(context);
},
);
useOnAppLifecycleStateChange((previous, current) {
switch (current) {
@@ -413,6 +413,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
store = components.core.store,
context = profileContext,
fragmentManager = parentFragmentManager,
addonManager = components.core.addonManager,
addonEvents = components.addonEvents,
),
owner = this,
view = view
@@ -6,8 +6,9 @@ package eu.weblibre.flutter_mozilla_components.addons
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -18,6 +19,7 @@ import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.webextension.PermissionPromptResponse
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment
import mozilla.components.feature.addons.ui.PermissionsDialogFragment
import mozilla.components.lib.state.ext.flowScoped
@@ -35,6 +37,8 @@ class WebExtensionPromptFeature(
private val store: BrowserStore,
private val context: Context,
private val fragmentManager: FragmentManager,
private val addonManager: AddonManager,
private val addonEvents: GeckoAddonEvents,
) : LifecycleAwareFeature {
/**
@@ -68,10 +72,8 @@ class WebExtensionPromptFeature(
}
private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) {
// The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView)
// extension into a minimal add-on. The missing metadata will be fetched when the user
// opens the add-ons manager.
val addon = Addon.newFromWebExtension(promptRequest.extension)
val installedState = addonManager.toInstalledState(promptRequest.extension)
val addon = Addon.newFromWebExtension(promptRequest.extension, installedState)
when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> handlePermissionRequest(
addon,
@@ -105,6 +107,7 @@ class WebExtensionPromptFeature(
promptRequest = promptRequest,
permissions = promptRequest.permissions,
origins = promptRequest.origins,
dataCollectionPermissions = promptRequest.dataCollectionPermissions,
)
}
@@ -112,21 +115,28 @@ class WebExtensionPromptFeature(
addon: Addon,
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Optional,
) {
val shouldGrantWithoutPrompt = Addon.localizePermissions(promptRequest.permissions, context).isEmpty()
val shouldGrantWithoutPrompt = Addon.localizePermissions(
promptRequest.permissions,
context,
).isEmpty() && promptRequest.origins.isEmpty() && promptRequest.dataCollectionPermissions.isEmpty()
// If we don't have any promptable permissions, just proceed.
if (shouldGrantWithoutPrompt) {
promptRequest.onConfirm(true)
consumePromptRequest()
handlePermissions(
promptRequest = promptRequest,
granted = true,
privateBrowsingAllowed = false,
technicalAndInteractionDataGranted = false,
)
return
}
showPermissionDialog(
// This is a bit of a hack so that the permission prompt only lists
// the optional permissions that are requested.
addon = addon.copy(permissions = promptRequest.permissions),
addon = addon,
promptRequest = promptRequest,
permissions = promptRequest.permissions,
origins = promptRequest.origins,
dataCollectionPermissions = promptRequest.dataCollectionPermissions,
forOptionalPermissions = true,
)
}
@@ -141,6 +151,10 @@ class WebExtensionPromptFeature(
onConfirmButtonClicked = { _ ->
consumePromptRequest()
},
onExtensionSettingsLinkClicked = {
openAddonSettings(it.id)
consumePromptRequest()
},
)
dialog.show(fragmentManager, POST_INSTALLATION_DIALOG_FRAGMENT_TAG)
}
@@ -159,6 +173,7 @@ class WebExtensionPromptFeature(
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
permissions: List<String> = emptyList(),
origins: List<String> = emptyList(),
dataCollectionPermissions: List<String> = emptyList(),
forOptionalPermissions: Boolean = false,
) {
if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) {
@@ -166,26 +181,23 @@ class WebExtensionPromptFeature(
addon = addon,
permissions = permissions,
origins = origins,
dataCollectionPermissions = emptyList(),
dataCollectionPermissions = dataCollectionPermissions,
forOptionalPermissions = forOptionalPermissions,
onPositiveButtonClicked = { _, privateBrowsingAllowed, _ ->
onPositiveButtonClicked = { _, privateBrowsingAllowed, technicalAndInteractionDataGranted ->
handlePermissions(
promptRequest,
granted = true,
privateBrowsingAllowed = privateBrowsingAllowed,
technicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
)
},
onNegativeButtonClicked = {
when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> {
promptRequest.onConfirm(false)
}
is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> {
promptRequest.onConfirm(PermissionPromptResponse(isPermissionsGranted = false))
}
}
consumePromptRequest()
handlePermissions(
promptRequest = promptRequest,
granted = false,
privateBrowsingAllowed = false,
technicalAndInteractionDataGranted = false,
)
},
)
dialog.show(
@@ -197,7 +209,7 @@ class WebExtensionPromptFeature(
private fun tryToReAttachButtonHandlersToPreviousDialog() {
findPreviousDialogFragment()?.let { dialog ->
dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed, _ ->
dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed, technicalAndInteractionDataGranted ->
store.state.webExtensionPromptRequest?.let { promptRequest ->
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions &&
addon.id == promptRequest.extension.id
@@ -206,26 +218,43 @@ class WebExtensionPromptFeature(
promptRequest,
granted = true,
privateBrowsingAllowed = privateBrowsingAllowed,
technicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
)
}
}
}
dialog.onNegativeButtonClicked = {
store.state.webExtensionPromptRequest?.let { promptRequest ->
handlePermissions(
promptRequest,
granted = false,
privateBrowsingAllowed = false,
)
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions) {
handlePermissions(
promptRequest,
granted = false,
privateBrowsingAllowed = false,
technicalAndInteractionDataGranted = false,
)
}
}
}
}
findPreviousPostInstallationDialogFragment()?.let { dialog ->
dialog.onDismissed = {
store.state.webExtensionPromptRequest?.let {
consumePromptRequest()
}
}
dialog.onExtensionSettingsLinkClicked = {
openAddonSettings(it.id)
consumePromptRequest()
}
}
}
private fun handlePermissions(
promptRequest: WebExtensionPromptRequest,
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
granted: Boolean,
privateBrowsingAllowed: Boolean,
technicalAndInteractionDataGranted: Boolean,
) {
when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> {
@@ -236,21 +265,18 @@ class WebExtensionPromptFeature(
val response = PermissionPromptResponse(
isPermissionsGranted = granted,
isPrivateModeGranted = privateBrowsingAllowed,
isTechnicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
)
promptRequest.onConfirm(response)
}
is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> {
// opt-out
}
is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> {
// opt-out
}
}
consumePromptRequest()
}
private fun openAddonSettings(addonId: String) {
addonEvents.onOpenAddonSettingsRequested(addonId) { }
}
private fun consumePromptRequest() {
store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction)
}
@@ -263,6 +289,11 @@ class WebExtensionPromptFeature(
return fragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment
}
private fun findPreviousPostInstallationDialogFragment(): AddonInstallationDialogFragment? {
return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG)
as? AddonInstallationDialogFragment
}
private fun hasExistingAddonPostInstallationDialogFragment(): Boolean {
return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG)
as? AddonInstallationDialogFragment != null
@@ -355,7 +386,7 @@ class WebExtensionPromptFeature(
message: String,
) {
context.let {
AlertDialog.Builder(it).setTitle(title)
MaterialAlertDialogBuilder(it).setTitle(title)
.setPositiveButton(android.R.string.ok) { _, _ -> }.setCancelable(false).setMessage(
message,
).show().withCenterAlignedButtons()
@@ -9235,6 +9235,23 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onOpenAddonSettingsRequested(addonIdArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onOpenAddonSettingsRequested$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(addonIdArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoSuggestionApi {
@@ -26,6 +26,7 @@ class GeckoAddonService extends GeckoAddonEvents {
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
final _popupSubject = PublishSubject<ExtensionPopupEvent>();
final _openAddonSettingsSubject = PublishSubject<String>();
Stream<ExtensionDataEvent> get browserExtensionStream =>
_browserExtensionSubject.stream;
@@ -36,6 +37,8 @@ class GeckoAddonService extends GeckoAddonEvents {
_browserIconSubject.stream;
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
Stream<String> get openAddonSettingsStream =>
_openAddonSettingsSubject.stream;
Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
return _api.getAddons(allowCache);
@@ -189,6 +192,11 @@ class GeckoAddonService extends GeckoAddonEvents {
_popupSubject.add((extensionId: extensionId, extensionName: extensionName));
}
@override
void onOpenAddonSettingsRequested(String addonId) {
_openAddonSettingsSubject.add(addonId);
}
GeckoAddonService.setUp({
BinaryMessenger? binaryMessenger,
GeckoAddonsApi? api,
@@ -207,5 +215,6 @@ class GeckoAddonService extends GeckoAddonEvents {
await _browserIconSubject.close();
await _pageIconSubject.close();
await _popupSubject.close();
await _openAddonSettingsSubject.close();
}
}
File diff suppressed because it is too large Load Diff
@@ -1962,10 +1962,7 @@ abstract class GeckoAddonsApi {
);
@async
List<AddonListing> getFeaturedAddonListings(
AddonStoreApp app,
int pageSize,
);
List<AddonListing> getFeaturedAddonListings(AddonStoreApp app, int pageSize);
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
@@ -2026,6 +2023,8 @@ abstract class GeckoAddonEvents {
);
void onWebExtensionPopupRequested(String extensionId, String extensionName);
void onOpenAddonSettingsRequested(String addonId);
}
@HostApi()