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) { useOnAppLifecycleStateChange((previous, current) {
switch (current) { switch (current) {
@@ -413,6 +413,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
store = components.core.store, store = components.core.store,
context = profileContext, context = profileContext,
fragmentManager = parentFragmentManager, fragmentManager = parentFragmentManager,
addonManager = components.core.addonManager,
addonEvents = components.addonEvents,
), ),
owner = this, owner = this,
view = view view = view
@@ -6,8 +6,9 @@ package eu.weblibre.flutter_mozilla_components.addons
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager 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.CoroutineScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged 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.PermissionPromptResponse
import mozilla.components.concept.engine.webextension.WebExtensionInstallException import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon 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.AddonInstallationDialogFragment
import mozilla.components.feature.addons.ui.PermissionsDialogFragment import mozilla.components.feature.addons.ui.PermissionsDialogFragment
import mozilla.components.lib.state.ext.flowScoped import mozilla.components.lib.state.ext.flowScoped
@@ -35,6 +37,8 @@ class WebExtensionPromptFeature(
private val store: BrowserStore, private val store: BrowserStore,
private val context: Context, private val context: Context,
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
private val addonManager: AddonManager,
private val addonEvents: GeckoAddonEvents,
) : LifecycleAwareFeature { ) : LifecycleAwareFeature {
/** /**
@@ -68,10 +72,8 @@ class WebExtensionPromptFeature(
} }
private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) { private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) {
// The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView) val installedState = addonManager.toInstalledState(promptRequest.extension)
// extension into a minimal add-on. The missing metadata will be fetched when the user val addon = Addon.newFromWebExtension(promptRequest.extension, installedState)
// opens the add-ons manager.
val addon = Addon.newFromWebExtension(promptRequest.extension)
when (promptRequest) { when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> handlePermissionRequest( is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> handlePermissionRequest(
addon, addon,
@@ -105,6 +107,7 @@ class WebExtensionPromptFeature(
promptRequest = promptRequest, promptRequest = promptRequest,
permissions = promptRequest.permissions, permissions = promptRequest.permissions,
origins = promptRequest.origins, origins = promptRequest.origins,
dataCollectionPermissions = promptRequest.dataCollectionPermissions,
) )
} }
@@ -112,21 +115,28 @@ class WebExtensionPromptFeature(
addon: Addon, addon: Addon,
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Optional, 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 we don't have any promptable permissions, just proceed.
if (shouldGrantWithoutPrompt) { if (shouldGrantWithoutPrompt) {
promptRequest.onConfirm(true) handlePermissions(
consumePromptRequest() promptRequest = promptRequest,
granted = true,
privateBrowsingAllowed = false,
technicalAndInteractionDataGranted = false,
)
return return
} }
showPermissionDialog( showPermissionDialog(
// This is a bit of a hack so that the permission prompt only lists addon = addon,
// the optional permissions that are requested.
addon = addon.copy(permissions = promptRequest.permissions),
promptRequest = promptRequest, promptRequest = promptRequest,
permissions = promptRequest.permissions, permissions = promptRequest.permissions,
origins = promptRequest.origins,
dataCollectionPermissions = promptRequest.dataCollectionPermissions,
forOptionalPermissions = true, forOptionalPermissions = true,
) )
} }
@@ -141,6 +151,10 @@ class WebExtensionPromptFeature(
onConfirmButtonClicked = { _ -> onConfirmButtonClicked = { _ ->
consumePromptRequest() consumePromptRequest()
}, },
onExtensionSettingsLinkClicked = {
openAddonSettings(it.id)
consumePromptRequest()
},
) )
dialog.show(fragmentManager, POST_INSTALLATION_DIALOG_FRAGMENT_TAG) dialog.show(fragmentManager, POST_INSTALLATION_DIALOG_FRAGMENT_TAG)
} }
@@ -159,6 +173,7 @@ class WebExtensionPromptFeature(
promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions, promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
permissions: List<String> = emptyList(), permissions: List<String> = emptyList(),
origins: List<String> = emptyList(), origins: List<String> = emptyList(),
dataCollectionPermissions: List<String> = emptyList(),
forOptionalPermissions: Boolean = false, forOptionalPermissions: Boolean = false,
) { ) {
if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) {
@@ -166,26 +181,23 @@ class WebExtensionPromptFeature(
addon = addon, addon = addon,
permissions = permissions, permissions = permissions,
origins = origins, origins = origins,
dataCollectionPermissions = emptyList(), dataCollectionPermissions = dataCollectionPermissions,
forOptionalPermissions = forOptionalPermissions, forOptionalPermissions = forOptionalPermissions,
onPositiveButtonClicked = { _, privateBrowsingAllowed, _ -> onPositiveButtonClicked = { _, privateBrowsingAllowed, technicalAndInteractionDataGranted ->
handlePermissions( handlePermissions(
promptRequest, promptRequest,
granted = true, granted = true,
privateBrowsingAllowed = privateBrowsingAllowed, privateBrowsingAllowed = privateBrowsingAllowed,
technicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
) )
}, },
onNegativeButtonClicked = { onNegativeButtonClicked = {
when (promptRequest) { handlePermissions(
is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { promptRequest = promptRequest,
promptRequest.onConfirm(false) granted = false,
} privateBrowsingAllowed = false,
technicalAndInteractionDataGranted = false,
is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> { )
promptRequest.onConfirm(PermissionPromptResponse(isPermissionsGranted = false))
}
}
consumePromptRequest()
}, },
) )
dialog.show( dialog.show(
@@ -197,7 +209,7 @@ class WebExtensionPromptFeature(
private fun tryToReAttachButtonHandlersToPreviousDialog() { private fun tryToReAttachButtonHandlersToPreviousDialog() {
findPreviousDialogFragment()?.let { dialog -> findPreviousDialogFragment()?.let { dialog ->
dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed, _ -> dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed, technicalAndInteractionDataGranted ->
store.state.webExtensionPromptRequest?.let { promptRequest -> store.state.webExtensionPromptRequest?.let { promptRequest ->
if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions && if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions &&
addon.id == promptRequest.extension.id addon.id == promptRequest.extension.id
@@ -206,26 +218,43 @@ class WebExtensionPromptFeature(
promptRequest, promptRequest,
granted = true, granted = true,
privateBrowsingAllowed = privateBrowsingAllowed, privateBrowsingAllowed = privateBrowsingAllowed,
technicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
) )
} }
} }
} }
dialog.onNegativeButtonClicked = { dialog.onNegativeButtonClicked = {
store.state.webExtensionPromptRequest?.let { promptRequest -> store.state.webExtensionPromptRequest?.let { promptRequest ->
handlePermissions( if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions) {
promptRequest, handlePermissions(
granted = false, promptRequest,
privateBrowsingAllowed = false, 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( private fun handlePermissions(
promptRequest: WebExtensionPromptRequest, promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions,
granted: Boolean, granted: Boolean,
privateBrowsingAllowed: Boolean, privateBrowsingAllowed: Boolean,
technicalAndInteractionDataGranted: Boolean,
) { ) {
when (promptRequest) { when (promptRequest) {
is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> {
@@ -236,21 +265,18 @@ class WebExtensionPromptFeature(
val response = PermissionPromptResponse( val response = PermissionPromptResponse(
isPermissionsGranted = granted, isPermissionsGranted = granted,
isPrivateModeGranted = privateBrowsingAllowed, isPrivateModeGranted = privateBrowsingAllowed,
isTechnicalAndInteractionDataGranted = technicalAndInteractionDataGranted,
) )
promptRequest.onConfirm(response) promptRequest.onConfirm(response)
} }
is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> {
// opt-out
}
is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> {
// opt-out
}
} }
consumePromptRequest() consumePromptRequest()
} }
private fun openAddonSettings(addonId: String) {
addonEvents.onOpenAddonSettingsRequested(addonId) { }
}
private fun consumePromptRequest() { private fun consumePromptRequest() {
store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction) store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction)
} }
@@ -263,6 +289,11 @@ class WebExtensionPromptFeature(
return fragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment 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 { private fun hasExistingAddonPostInstallationDialogFragment(): Boolean {
return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG) return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG)
as? AddonInstallationDialogFragment != null as? AddonInstallationDialogFragment != null
@@ -355,7 +386,7 @@ class WebExtensionPromptFeature(
message: String, message: String,
) { ) {
context.let { context.let {
AlertDialog.Builder(it).setTitle(title) MaterialAlertDialogBuilder(it).setTitle(title)
.setPositiveButton(android.R.string.ok) { _, _ -> }.setCancelable(false).setMessage( .setPositiveButton(android.R.string.ok) { _, _ -> }.setCancelable(false).setMessage(
message, message,
).show().withCenterAlignedButtons() ).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. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoSuggestionApi { interface GeckoSuggestionApi {
@@ -26,6 +26,7 @@ class GeckoAddonService extends GeckoAddonEvents {
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>(); final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>(); final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
final _popupSubject = PublishSubject<ExtensionPopupEvent>(); final _popupSubject = PublishSubject<ExtensionPopupEvent>();
final _openAddonSettingsSubject = PublishSubject<String>();
Stream<ExtensionDataEvent> get browserExtensionStream => Stream<ExtensionDataEvent> get browserExtensionStream =>
_browserExtensionSubject.stream; _browserExtensionSubject.stream;
@@ -36,6 +37,8 @@ class GeckoAddonService extends GeckoAddonEvents {
_browserIconSubject.stream; _browserIconSubject.stream;
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream; Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream; Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
Stream<String> get openAddonSettingsStream =>
_openAddonSettingsSubject.stream;
Future<List<AddonInfo>> getAddons({bool allowCache = true}) { Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
return _api.getAddons(allowCache); return _api.getAddons(allowCache);
@@ -189,6 +192,11 @@ class GeckoAddonService extends GeckoAddonEvents {
_popupSubject.add((extensionId: extensionId, extensionName: extensionName)); _popupSubject.add((extensionId: extensionId, extensionName: extensionName));
} }
@override
void onOpenAddonSettingsRequested(String addonId) {
_openAddonSettingsSubject.add(addonId);
}
GeckoAddonService.setUp({ GeckoAddonService.setUp({
BinaryMessenger? binaryMessenger, BinaryMessenger? binaryMessenger,
GeckoAddonsApi? api, GeckoAddonsApi? api,
@@ -207,5 +215,6 @@ class GeckoAddonService extends GeckoAddonEvents {
await _browserIconSubject.close(); await _browserIconSubject.close();
await _pageIconSubject.close(); await _pageIconSubject.close();
await _popupSubject.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 @async
List<AddonListing> getFeaturedAddonListings( List<AddonListing> getFeaturedAddonListings(AddonStoreApp app, int pageSize);
AddonStoreApp app,
int pageSize,
);
void invokeAddonAction(String extensionId, WebExtensionActionType actionType); void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
@@ -2026,6 +2023,8 @@ abstract class GeckoAddonEvents {
); );
void onWebExtensionPopupRequested(String extensionId, String extensionName); void onWebExtensionPopupRequested(String extensionId, String extensionName);
void onOpenAddonSettingsRequested(String addonId);
} }
@HostApi() @HostApi()