diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml
index 0601a794..03b7d90b 100644
--- a/apps/weblibre/android/app/src/main/AndroidManifest.xml
+++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml
@@ -209,38 +209,6 @@
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
android:exported="false" />
-
-
-
-
-
-
-
-
-
-
@android:color/transparent
- @android:color/transparent
-
-
diff --git a/apps/weblibre/lib/core/routing/routes.addons.dart b/apps/weblibre/lib/core/routing/routes.addons.dart
new file mode 100644
index 00000000..c43dded7
--- /dev/null
+++ b/apps/weblibre/lib/core/routing/routes.addons.dart
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+part of 'routes.dart';
+
+@TypedGoRoute(
+ name: 'AddonManagerRoute',
+ path: '/addons',
+ routes: [
+ TypedGoRoute(
+ name: 'AddonDetailsRoute',
+ path: 'details/:addonId',
+ ),
+ TypedGoRoute(
+ name: 'AddonPermissionsRoute',
+ path: 'permissions/:addonId',
+ ),
+ TypedGoRoute(
+ name: 'AddonInternalSettingsRoute',
+ path: 'settings/:addonId',
+ ),
+ ],
+)
+class AddonManagerRoute extends GoRouteData with $AddonManagerRoute {
+ const AddonManagerRoute();
+
+ @override
+ Widget build(BuildContext context, GoRouterState state) {
+ return const AddonManagerScreen();
+ }
+}
+
+class AddonDetailsRoute extends GoRouteData with $AddonDetailsRoute {
+ final String addonId;
+
+ const AddonDetailsRoute({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, GoRouterState state) {
+ return AddonDetailsScreen(addonId: addonId);
+ }
+}
+
+class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
+ final String addonId;
+
+ const AddonPermissionsRoute({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, GoRouterState state) {
+ return AddonPermissionsScreen(addonId: addonId);
+ }
+}
+
+class AddonInternalSettingsRoute extends GoRouteData
+ with $AddonInternalSettingsRoute {
+ final String addonId;
+
+ const AddonInternalSettingsRoute({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, GoRouterState state) {
+ return AddonInternalSettingsScreen(addonId: addonId);
+ }
+}
diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart
index 89f0129e..3b854623 100644
--- a/apps/weblibre/lib/core/routing/routes.dart
+++ b/apps/weblibre/lib/core/routing/routes.dart
@@ -28,6 +28,10 @@ import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
@@ -97,6 +101,7 @@ import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'
part 'routes.bangs.dart';
part 'routes.bookmarks.dart';
part 'routes.browser.dart';
+part 'routes.addons.dart';
part 'routes.feeds.dart';
part 'routes.g.dart';
part 'routes.history.dart';
diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart
index acb7f9c4..0fa879a2 100644
--- a/apps/weblibre/lib/core/routing/routes.g.dart
+++ b/apps/weblibre/lib/core/routing/routes.g.dart
@@ -13,6 +13,7 @@ List get $appRoutes => [
$bangMenuRoute,
$bookmarksRoute,
$browserRoute,
+ $addonManagerRoute,
$feedListRoute,
$historyRoute,
$profileListRoute,
@@ -931,6 +932,125 @@ extension on Map {
entries.where((element) => element.value == value).firstOrNull?.key;
}
+RouteBase get $addonManagerRoute => GoRouteData.$route(
+ path: '/addons',
+ name: 'AddonManagerRoute',
+ factory: $AddonManagerRoute._fromState,
+ routes: [
+ GoRouteData.$route(
+ path: 'details/:addonId',
+ name: 'AddonDetailsRoute',
+ factory: $AddonDetailsRoute._fromState,
+ ),
+ GoRouteData.$route(
+ path: 'permissions/:addonId',
+ name: 'AddonPermissionsRoute',
+ factory: $AddonPermissionsRoute._fromState,
+ ),
+ GoRouteData.$route(
+ path: 'settings/:addonId',
+ name: 'AddonInternalSettingsRoute',
+ factory: $AddonInternalSettingsRoute._fromState,
+ ),
+ ],
+);
+
+mixin $AddonManagerRoute on GoRouteData {
+ static AddonManagerRoute _fromState(GoRouterState state) =>
+ const AddonManagerRoute();
+
+ @override
+ String get location => GoRouteData.$location('/addons');
+
+ @override
+ void go(BuildContext context) => context.go(location);
+
+ @override
+ Future push(BuildContext context) => context.push(location);
+
+ @override
+ void pushReplacement(BuildContext context) =>
+ context.pushReplacement(location);
+
+ @override
+ void replace(BuildContext context) => context.replace(location);
+}
+
+mixin $AddonDetailsRoute on GoRouteData {
+ static AddonDetailsRoute _fromState(GoRouterState state) =>
+ AddonDetailsRoute(addonId: state.pathParameters['addonId']!);
+
+ AddonDetailsRoute get _self => this as AddonDetailsRoute;
+
+ @override
+ String get location => GoRouteData.$location(
+ '/addons/details/${Uri.encodeComponent(_self.addonId)}',
+ );
+
+ @override
+ void go(BuildContext context) => context.go(location);
+
+ @override
+ Future push(BuildContext context) => context.push(location);
+
+ @override
+ void pushReplacement(BuildContext context) =>
+ context.pushReplacement(location);
+
+ @override
+ void replace(BuildContext context) => context.replace(location);
+}
+
+mixin $AddonPermissionsRoute on GoRouteData {
+ static AddonPermissionsRoute _fromState(GoRouterState state) =>
+ AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
+
+ AddonPermissionsRoute get _self => this as AddonPermissionsRoute;
+
+ @override
+ String get location => GoRouteData.$location(
+ '/addons/permissions/${Uri.encodeComponent(_self.addonId)}',
+ );
+
+ @override
+ void go(BuildContext context) => context.go(location);
+
+ @override
+ Future push(BuildContext context) => context.push(location);
+
+ @override
+ void pushReplacement(BuildContext context) =>
+ context.pushReplacement(location);
+
+ @override
+ void replace(BuildContext context) => context.replace(location);
+}
+
+mixin $AddonInternalSettingsRoute on GoRouteData {
+ static AddonInternalSettingsRoute _fromState(GoRouterState state) =>
+ AddonInternalSettingsRoute(addonId: state.pathParameters['addonId']!);
+
+ AddonInternalSettingsRoute get _self => this as AddonInternalSettingsRoute;
+
+ @override
+ String get location => GoRouteData.$location(
+ '/addons/settings/${Uri.encodeComponent(_self.addonId)}',
+ );
+
+ @override
+ void go(BuildContext context) => context.go(location);
+
+ @override
+ Future push(BuildContext context) => context.push(location);
+
+ @override
+ void pushReplacement(BuildContext context) =>
+ context.pushReplacement(location);
+
+ @override
+ void replace(BuildContext context) => context.replace(location);
+}
+
RouteBase get $feedListRoute => GoRouteData.$route(
path: '/feeds',
name: 'FeedListRoute',
diff --git a/apps/weblibre/lib/features/addons/domain/providers.dart b/apps/weblibre/lib/features/addons/domain/providers.dart
new file mode 100644
index 00000000..1128d0cd
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/domain/providers.dart
@@ -0,0 +1,294 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:convert';
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:riverpod/experimental/persist.dart';
+import 'package:riverpod_annotation/experimental/persist.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/features/geckoview/domain/providers.dart';
+import 'package:weblibre/features/user/data/providers.dart';
+
+part 'providers.g.dart';
+
+sealed class AddonUpdateOutcome {
+ const AddonUpdateOutcome();
+}
+
+class AddonUpdateOutcomeAvailable extends AddonUpdateOutcome {
+ final AddonInfo addon;
+ final String availableVersion;
+
+ const AddonUpdateOutcomeAvailable({
+ required this.addon,
+ required this.availableVersion,
+ });
+}
+
+class AddonUpdateOutcomeUpToDate extends AddonUpdateOutcome {
+ const AddonUpdateOutcomeUpToDate();
+}
+
+class AddonUpdateOutcomeMissing extends AddonUpdateOutcome {
+ const AddonUpdateOutcomeMissing();
+}
+
+sealed class AddonUpdateRunResult {
+ const AddonUpdateRunResult();
+}
+
+class AddonUpdateRunDone extends AddonUpdateRunResult {
+ final String? message;
+
+ const AddonUpdateRunDone(this.message);
+}
+
+class AddonUpdateRunNoRemoteSource extends AddonUpdateRunResult {
+ const AddonUpdateRunNoRemoteSource();
+}
+
+class AddonUpdateRunFailed extends AddonUpdateRunResult {
+ const AddonUpdateRunFailed();
+}
+
+String _resolveAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
+ final latest = storeInfo?.latestVersion.trim();
+ return (latest != null && latest.isNotEmpty) ? latest : addon.version;
+}
+
+@Riverpod()
+class AddonDetails extends _$AddonDetails {
+ GeckoAddonService get _service => ref.read(addonServiceProvider);
+
+ Future _run(Future Function() action) async {
+ state = const AsyncLoading();
+ state = await AsyncValue.guard(action);
+
+ ref.invalidate(addonListProvider);
+ }
+
+ Future refresh() async {
+ ref.invalidateSelf();
+ await future;
+ }
+
+ Future install() async {
+ final current = state.value;
+ if (current == null) return;
+
+ await _run(() async {
+ await _service.installAddon(Uri.parse(current.downloadUrl));
+ return _service.getAddonById(addonId);
+ });
+ }
+
+ Future uninstall() async {
+ await _run(() async {
+ await _service.uninstallAddon(addonId);
+ return null;
+ });
+ }
+
+ Future setEnabled({required bool enabled}) async {
+ await _run(
+ () => enabled
+ ? _service.enableAddon(addonId)
+ : _service.disableAddon(addonId),
+ );
+ }
+
+ Future setAllowedInPrivateBrowsing({required bool allowed}) async {
+ await _run(
+ () => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed),
+ );
+ }
+
+ Future setAutoUpdateEnabled({required bool enabled}) async {
+ await _run(
+ () => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled),
+ );
+ }
+
+ @override
+ Future build(String addonId) {
+ return _service.getAddonById(addonId);
+ }
+}
+
+@Riverpod()
+Future addonStoreInfo(Ref ref, String addonId) {
+ return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
+}
+
+@Riverpod()
+Future lastAddonUpdateAttempt(
+ Ref ref,
+ String addonId,
+) {
+ return ref.read(addonServiceProvider).getLastAddonUpdateAttempt(addonId);
+}
+
+@Riverpod()
+class AddonUpdateCheck extends _$AddonUpdateCheck {
+ /// Refreshes store info and returns whether an update is available.
+ Future resolveAvailableUpdate() async {
+ final storeInfo = await ref
+ .read(addonServiceProvider)
+ .getAddonStoreInfo(addonId);
+ final fresh = await ref
+ .read(addonServiceProvider)
+ .getAddonById(addonId, allowCache: false);
+
+ if (fresh == null) return const AddonUpdateOutcomeMissing();
+
+ final available = _resolveAvailableVersion(fresh, storeInfo);
+ final hasUpdate =
+ fresh.installedVersion != null &&
+ available.isNotEmpty &&
+ fresh.installedVersion != available;
+
+ return hasUpdate
+ ? AddonUpdateOutcomeAvailable(addon: fresh, availableVersion: available)
+ : const AddonUpdateOutcomeUpToDate();
+ }
+
+ /// Triggers a remote update and awaits completion. Invalidates dependent
+ /// providers on completion.
+ Future triggerAndAwait() async {
+ state = const AsyncLoading();
+
+ final result = await AsyncValue.guard(() async {
+ final AddonUpdateAttemptInfo? attempt;
+ try {
+ attempt = await ref
+ .read(addonServiceProvider)
+ .triggerAddonUpdate(addonId);
+ } catch (error) {
+ final noRemote = error.toString().contains(
+ 'No remote update source is available for this locally installed extension.',
+ );
+ return noRemote
+ ? const AddonUpdateRunNoRemoteSource()
+ : const AddonUpdateRunFailed();
+ }
+
+ return attempt?.status == AddonUpdateStatus.error
+ ? const AddonUpdateRunFailed()
+ : AddonUpdateRunDone(attempt?.message);
+ });
+
+ state = result;
+ ref.invalidate(addonDetailsProvider(addonId));
+ ref.invalidate(lastAddonUpdateAttemptProvider(addonId));
+
+ return result.value ?? const AddonUpdateRunFailed();
+ }
+
+ @override
+ AsyncValue build(String addonId) =>
+ const AsyncData(AddonUpdateRunDone(null));
+}
+
+@Riverpod()
+class AddonList extends _$AddonList {
+ GeckoAddonService get _service => ref.read(addonServiceProvider);
+
+ Future refresh() async {
+ ref.invalidateSelf();
+ await future;
+ }
+
+ Future install(AddonInfo addon) async {
+ ref.read(addonBusyIdsProvider.notifier).add(addon.id);
+ try {
+ await _service.installAddon(Uri.parse(addon.downloadUrl));
+ ref.invalidate(addonDetailsProvider(addon.id));
+ ref.invalidateSelf();
+ await future;
+ } finally {
+ ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
+ }
+ }
+
+ Future uninstall(AddonInfo addon) async {
+ ref.read(addonBusyIdsProvider.notifier).add(addon.id);
+ try {
+ await _service.uninstallAddon(addon.id);
+ ref.invalidate(addonDetailsProvider(addon.id));
+ ref.invalidateSelf();
+ await future;
+ } finally {
+ ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
+ }
+ }
+
+ @override
+ Future> build() {
+ return _service.getAddons();
+ }
+}
+
+@Riverpod()
+class AddonBusyIds extends _$AddonBusyIds {
+ void add(String id) => state = {...state, id};
+ void remove(String id) => state = {...state}..remove(id);
+
+ @override
+ Set build() => const {};
+}
+
+@Riverpod(keepAlive: true)
+class PinnedAddonIds extends _$PinnedAddonIds {
+ void setPinned(String addonId, {required bool pinned}) {
+ if (pinned) {
+ if (!state.contains(addonId)) {
+ state = {...state, addonId};
+ }
+ } else if (state.contains(addonId)) {
+ state = {...state}..remove(addonId);
+ }
+ }
+
+ @override
+ Set build() {
+ persist(
+ ref.watch(riverpodDatabaseStorageProvider),
+ key: 'PinnedAddonIds',
+ encode: (state) => jsonEncode(state.toList()),
+ decode: (encoded) =>
+ (jsonDecode(encoded) as List).cast().toSet(),
+ );
+
+ return stateOrNull ?? const {};
+ }
+}
+
+@Riverpod()
+class BulkAddonUpdate extends _$BulkAddonUpdate {
+ Future triggerAll() async {
+ state = const AsyncLoading();
+ state = await AsyncValue.guard(() async {
+ await ref.read(addonServiceProvider).triggerAllAddonUpdates();
+ });
+ }
+
+ @override
+ AsyncValue build() => const AsyncData(null);
+}
diff --git a/apps/weblibre/lib/features/addons/domain/providers.g.dart b/apps/weblibre/lib/features/addons/domain/providers.g.dart
new file mode 100644
index 00000000..81226298
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/domain/providers.g.dart
@@ -0,0 +1,562 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'providers.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+
+@ProviderFor(AddonDetails)
+final addonDetailsProvider = AddonDetailsFamily._();
+
+final class AddonDetailsProvider
+ extends $AsyncNotifierProvider {
+ AddonDetailsProvider._({
+ required AddonDetailsFamily super.from,
+ required String super.argument,
+ }) : super(
+ retry: null,
+ name: r'addonDetailsProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonDetailsHash();
+
+ @override
+ String toString() {
+ return r'addonDetailsProvider'
+ ''
+ '($argument)';
+ }
+
+ @$internal
+ @override
+ AddonDetails create() => AddonDetails();
+
+ @override
+ bool operator ==(Object other) {
+ return other is AddonDetailsProvider && other.argument == argument;
+ }
+
+ @override
+ int get hashCode {
+ return argument.hashCode;
+ }
+}
+
+String _$addonDetailsHash() => r'26b4a33e9d17aced1d3fb5c6ff28921f611ca5b0';
+
+final class AddonDetailsFamily extends $Family
+ with
+ $ClassFamilyOverride<
+ AddonDetails,
+ AsyncValue,
+ AddonInfo?,
+ FutureOr,
+ String
+ > {
+ AddonDetailsFamily._()
+ : super(
+ retry: null,
+ name: r'addonDetailsProvider',
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ isAutoDispose: true,
+ );
+
+ AddonDetailsProvider call(String addonId) =>
+ AddonDetailsProvider._(argument: addonId, from: this);
+
+ @override
+ String toString() => r'addonDetailsProvider';
+}
+
+abstract class _$AddonDetails extends $AsyncNotifier {
+ late final _$args = ref.$arg as String;
+ String get addonId => _$args;
+
+ FutureOr build(String addonId);
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, AddonInfo?>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, AddonInfo?>,
+ AsyncValue,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, () => build(_$args));
+ }
+}
+
+@ProviderFor(addonStoreInfo)
+final addonStoreInfoProvider = AddonStoreInfoFamily._();
+
+final class AddonStoreInfoProvider
+ extends
+ $FunctionalProvider<
+ AsyncValue,
+ AddonStoreInfo?,
+ FutureOr
+ >
+ with $FutureModifier, $FutureProvider {
+ AddonStoreInfoProvider._({
+ required AddonStoreInfoFamily super.from,
+ required String super.argument,
+ }) : super(
+ retry: null,
+ name: r'addonStoreInfoProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonStoreInfoHash();
+
+ @override
+ String toString() {
+ return r'addonStoreInfoProvider'
+ ''
+ '($argument)';
+ }
+
+ @$internal
+ @override
+ $FutureProviderElement $createElement(
+ $ProviderPointer pointer,
+ ) => $FutureProviderElement(pointer);
+
+ @override
+ FutureOr create(Ref ref) {
+ final argument = this.argument as String;
+ return addonStoreInfo(ref, argument);
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is AddonStoreInfoProvider && other.argument == argument;
+ }
+
+ @override
+ int get hashCode {
+ return argument.hashCode;
+ }
+}
+
+String _$addonStoreInfoHash() => r'0024a540ecf6f1d55243d9dc963e04fbc212275e';
+
+final class AddonStoreInfoFamily extends $Family
+ with $FunctionalFamilyOverride, String> {
+ AddonStoreInfoFamily._()
+ : super(
+ retry: null,
+ name: r'addonStoreInfoProvider',
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ isAutoDispose: true,
+ );
+
+ AddonStoreInfoProvider call(String addonId) =>
+ AddonStoreInfoProvider._(argument: addonId, from: this);
+
+ @override
+ String toString() => r'addonStoreInfoProvider';
+}
+
+@ProviderFor(lastAddonUpdateAttempt)
+final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
+
+final class LastAddonUpdateAttemptProvider
+ extends
+ $FunctionalProvider<
+ AsyncValue,
+ AddonUpdateAttemptInfo?,
+ FutureOr
+ >
+ with
+ $FutureModifier,
+ $FutureProvider {
+ LastAddonUpdateAttemptProvider._({
+ required LastAddonUpdateAttemptFamily super.from,
+ required String super.argument,
+ }) : super(
+ retry: null,
+ name: r'lastAddonUpdateAttemptProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$lastAddonUpdateAttemptHash();
+
+ @override
+ String toString() {
+ return r'lastAddonUpdateAttemptProvider'
+ ''
+ '($argument)';
+ }
+
+ @$internal
+ @override
+ $FutureProviderElement $createElement(
+ $ProviderPointer pointer,
+ ) => $FutureProviderElement(pointer);
+
+ @override
+ FutureOr create(Ref ref) {
+ final argument = this.argument as String;
+ return lastAddonUpdateAttempt(ref, argument);
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is LastAddonUpdateAttemptProvider &&
+ other.argument == argument;
+ }
+
+ @override
+ int get hashCode {
+ return argument.hashCode;
+ }
+}
+
+String _$lastAddonUpdateAttemptHash() =>
+ r'79847d9f5720fea1f742d57c3b17272ffd980cc1';
+
+final class LastAddonUpdateAttemptFamily extends $Family
+ with $FunctionalFamilyOverride, String> {
+ LastAddonUpdateAttemptFamily._()
+ : super(
+ retry: null,
+ name: r'lastAddonUpdateAttemptProvider',
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ isAutoDispose: true,
+ );
+
+ LastAddonUpdateAttemptProvider call(String addonId) =>
+ LastAddonUpdateAttemptProvider._(argument: addonId, from: this);
+
+ @override
+ String toString() => r'lastAddonUpdateAttemptProvider';
+}
+
+@ProviderFor(AddonUpdateCheck)
+final addonUpdateCheckProvider = AddonUpdateCheckFamily._();
+
+final class AddonUpdateCheckProvider
+ extends
+ $NotifierProvider> {
+ AddonUpdateCheckProvider._({
+ required AddonUpdateCheckFamily super.from,
+ required String super.argument,
+ }) : super(
+ retry: null,
+ name: r'addonUpdateCheckProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonUpdateCheckHash();
+
+ @override
+ String toString() {
+ return r'addonUpdateCheckProvider'
+ ''
+ '($argument)';
+ }
+
+ @$internal
+ @override
+ AddonUpdateCheck create() => AddonUpdateCheck();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(AsyncValue value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider>(
+ value,
+ ),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is AddonUpdateCheckProvider && other.argument == argument;
+ }
+
+ @override
+ int get hashCode {
+ return argument.hashCode;
+ }
+}
+
+String _$addonUpdateCheckHash() => r'4ef375b5cd9b0eb89fbbdaff3f93a35482191af5';
+
+final class AddonUpdateCheckFamily extends $Family
+ with
+ $ClassFamilyOverride<
+ AddonUpdateCheck,
+ AsyncValue,
+ AsyncValue,
+ AsyncValue,
+ String
+ > {
+ AddonUpdateCheckFamily._()
+ : super(
+ retry: null,
+ name: r'addonUpdateCheckProvider',
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ isAutoDispose: true,
+ );
+
+ AddonUpdateCheckProvider call(String addonId) =>
+ AddonUpdateCheckProvider._(argument: addonId, from: this);
+
+ @override
+ String toString() => r'addonUpdateCheckProvider';
+}
+
+abstract class _$AddonUpdateCheck
+ extends $Notifier> {
+ late final _$args = ref.$arg as String;
+ String get addonId => _$args;
+
+ AsyncValue build(String addonId);
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref =
+ this.ref
+ as $Ref<
+ AsyncValue,
+ AsyncValue
+ >;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier<
+ AsyncValue,
+ AsyncValue
+ >,
+ AsyncValue,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, () => build(_$args));
+ }
+}
+
+@ProviderFor(AddonList)
+final addonListProvider = AddonListProvider._();
+
+final class AddonListProvider
+ extends $AsyncNotifierProvider> {
+ AddonListProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'addonListProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonListHash();
+
+ @$internal
+ @override
+ AddonList create() => AddonList();
+}
+
+String _$addonListHash() => r'7625b69a433d073e186571453e39b557d8b48a5d';
+
+abstract class _$AddonList extends $AsyncNotifier> {
+ FutureOr> build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref>, List>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier>, List>,
+ AsyncValue>,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
+
+@ProviderFor(AddonBusyIds)
+final addonBusyIdsProvider = AddonBusyIdsProvider._();
+
+final class AddonBusyIdsProvider
+ extends $NotifierProvider> {
+ AddonBusyIdsProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'addonBusyIdsProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonBusyIdsHash();
+
+ @$internal
+ @override
+ AddonBusyIds create() => AddonBusyIds();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(Set value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider>(value),
+ );
+ }
+}
+
+String _$addonBusyIdsHash() => r'6f9761320d42b2b5132936797617fdb13b718dd3';
+
+abstract class _$AddonBusyIds extends $Notifier> {
+ Set build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, Set>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, Set>,
+ Set,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
+
+@ProviderFor(PinnedAddonIds)
+final pinnedAddonIdsProvider = PinnedAddonIdsProvider._();
+
+final class PinnedAddonIdsProvider
+ extends $NotifierProvider> {
+ PinnedAddonIdsProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'pinnedAddonIdsProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$pinnedAddonIdsHash();
+
+ @$internal
+ @override
+ PinnedAddonIds create() => PinnedAddonIds();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(Set value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider>(value),
+ );
+ }
+}
+
+String _$pinnedAddonIdsHash() => r'4f46cd69d4817e6e19e4d04f4fdcf83d5e2efb8c';
+
+abstract class _$PinnedAddonIds extends $Notifier> {
+ Set build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, Set>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, Set>,
+ Set,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
+
+@ProviderFor(BulkAddonUpdate)
+final bulkAddonUpdateProvider = BulkAddonUpdateProvider._();
+
+final class BulkAddonUpdateProvider
+ extends $NotifierProvider> {
+ BulkAddonUpdateProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'bulkAddonUpdateProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$bulkAddonUpdateHash();
+
+ @$internal
+ @override
+ BulkAddonUpdate create() => BulkAddonUpdate();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(AsyncValue value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider>(value),
+ );
+ }
+}
+
+String _$bulkAddonUpdateHash() => r'2605711734f9eb6af70e721a4337556a7ea85512';
+
+abstract class _$BulkAddonUpdate extends $Notifier> {
+ AsyncValue build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, AsyncValue>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, AsyncValue>,
+ AsyncValue,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/extensions/addon_info.dart b/apps/weblibre/lib/features/addons/extensions/addon_info.dart
new file mode 100644
index 00000000..31204cd1
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/extensions/addon_info.dart
@@ -0,0 +1,32 @@
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+
+extension AddonInfoUi on AddonInfo {
+ bool get hasOptionsPage => optionsPageUrl?.isNotEmpty ?? false;
+
+ bool get canUserToggleEnabled {
+ return switch (disabledReason) {
+ AddonDisabledReason.blocklisted ||
+ AddonDisabledReason.notCorrectlySigned ||
+ AddonDisabledReason.incompatible => false,
+ _ => true,
+ };
+ }
+
+ String? get statusBannerMessage {
+ return switch (disabledReason) {
+ AddonDisabledReason.blocklisted =>
+ 'This extension has been blocklisted and should remain disabled.',
+ AddonDisabledReason.notCorrectlySigned =>
+ 'This extension is not correctly signed and cannot be safely enabled.',
+ AddonDisabledReason.incompatible =>
+ 'This extension is incompatible with the current app version.',
+ AddonDisabledReason.softBlocked =>
+ isEnabled
+ ? 'This extension is soft-blocked. Use caution while it remains enabled.'
+ : 'This extension is soft-blocked, but it can still be re-enabled.',
+ AddonDisabledReason.unsupported =>
+ 'This extension is installed, but WebLibre does not currently support it.',
+ _ => null,
+ };
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart
new file mode 100644
index 00000000..6ba1f68f
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart
@@ -0,0 +1,638 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/addons/domain/providers.dart';
+import 'package:weblibre/features/addons/extensions/addon_info.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
+import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
+import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
+import 'package:weblibre/utils/ui_helper.dart';
+
+class AddonDetailsScreen extends ConsumerWidget {
+ final String addonId;
+
+ const AddonDetailsScreen({required this.addonId, super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonAsync = ref.watch(addonDetailsProvider(addonId));
+ final addon = addonAsync.value;
+
+ if (addonAsync.isLoading && addon == null) {
+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
+ }
+
+ if (addon == null) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Extension')),
+ body: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ addonAsync.error?.toString() ??
+ 'This extension could not be found.',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ );
+ }
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(addon.displayName),
+ actions: [
+ IconButton(
+ onPressed: addonAsync.isLoading
+ ? null
+ : ref.read(addonDetailsProvider(addonId).notifier).refresh,
+ icon: const Icon(Icons.refresh),
+ ),
+ ],
+ ),
+ body: RefreshIndicator(
+ onRefresh: ref.read(addonDetailsProvider(addonId).notifier).refresh,
+ child: _AddonDetailsBody(addonId: addonId),
+ ),
+ );
+ }
+}
+
+class _AddonDetailsBody extends ConsumerWidget {
+ final String addonId;
+
+ const _AddonDetailsBody({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
+
+ final addon = ref.watch(
+ addonDetailsProvider(addonId).select((value) => value.value),
+ );
+ if (addon == null) return const SizedBox.shrink();
+
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _AddonHeader(addon: addon),
+ const SizedBox(height: 16),
+ if (addon.isInstalled) ...[
+ _ManagementSection(addonId: addonId),
+ const SizedBox(height: 16),
+ _UpdatesSection(addonId: addonId),
+ ] else ...[
+ _InstallButton(addonId: addonId),
+ const SizedBox(height: 12),
+ OutlinedButton.icon(
+ onPressed: () =>
+ AddonPermissionsRoute(addonId: addon.id).push(context),
+ icon: const Icon(Icons.privacy_tip_outlined),
+ label: const Text('View Permissions'),
+ ),
+ ],
+ const SizedBox(height: 16),
+ Text('Details', style: theme.textTheme.titleMedium),
+ const SizedBox(height: 8),
+ _DetailsCard(addon: addon),
+ const SizedBox(height: 16),
+ Text('Description', style: theme.textTheme.titleMedium),
+ const SizedBox(height: 8),
+ _DescriptionCard(addon: addon),
+ ],
+ );
+ }
+}
+
+class _InstallButton extends ConsumerWidget {
+ final String addonId;
+
+ const _InstallButton({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonAsync = ref.watch(addonDetailsProvider(addonId));
+ final addon = addonAsync.value;
+
+ return FilledButton.icon(
+ onPressed: (addonAsync.isLoading || addon == null)
+ ? null
+ : () async {
+ final displayName = addon.displayName;
+
+ await ref.read(addonDetailsProvider(addonId).notifier).install();
+
+ if (!context.mounted) return;
+
+ showInfoMessage(context, '$displayName installed');
+ },
+ icon: const Icon(Icons.download),
+ label: const Text('Install Extension'),
+ );
+ }
+}
+
+class _AddonHeader extends StatelessWidget {
+ final AddonInfo addon;
+
+ const _AddonHeader({required this.addon});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ AddonIconView(addon: addon, size: 56),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ addon.displayName,
+ style: theme.textTheme.headlineSmall,
+ ),
+ if ((addon.summary ?? '').isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(addon.summary!),
+ ],
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ Chip(
+ label: Text(
+ addon.isInstalled
+ ? (addon.isEnabled ? 'Installed' : 'Disabled')
+ : 'Available',
+ ),
+ ),
+ if (addon.isAllowedInPrivateBrowsing)
+ const Chip(label: Text('Private Browsing')),
+ if (addon.ratingAverage != null)
+ Chip(
+ avatar: const Icon(Icons.star, size: 18),
+ label: Text(
+ '${addon.ratingAverage!.toStringAsFixed(1)}'
+ ' (${addon.ratingReviews ?? 0})',
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ AddonStatusBanner(addon: addon),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _ManagementSection extends ConsumerWidget {
+ final String addonId;
+
+ const _ManagementSection({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
+
+ final addonAsync = ref.watch(addonDetailsProvider(addonId));
+ final addon = addonAsync.value;
+
+ if (addon == null) return const SizedBox.shrink();
+
+ final globalAutoUpdate = ref.watch(addonAutoUpdateProvider);
+ final isLocalFileInstalled = addon.isLocalFileInstalled;
+
+ final isPinned = ref.watch(pinnedAddonIdsProvider).contains(addonId);
+
+ final (
+ globalAutoUpdateEnabled,
+ canChangePerAddonAutoUpdate,
+ ) = globalAutoUpdate.when(
+ data: (enabled) =>
+ (enabled, !addonAsync.isLoading && enabled && !isLocalFileInstalled),
+ loading: () => (true, false),
+ error: (_, _) => (true, false),
+ );
+
+ final autoUpdateSubtitle = switch ((
+ isLocalFileInstalled,
+ addon.isAutoUpdateEnabled,
+ globalAutoUpdateEnabled,
+ )) {
+ (_, _, false) => 'Global automatic updates are disabled.',
+ (true, _, true) =>
+ 'Run a manual update once and restart the app before automatic updates can be enabled.',
+ (false, true, true) =>
+ 'Allow this extension to receive background updates.',
+ (false, false, true) =>
+ 'Background updates are disabled for this extension.',
+ };
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Management', style: theme.textTheme.titleMedium),
+ const SizedBox(height: 8),
+ Card(
+ child: Column(
+ children: [
+ if (addon.isSupported)
+ SwitchListTile.adaptive(
+ title: const Text('Enabled'),
+ subtitle: Text(
+ addon.canUserToggleEnabled
+ ? 'Allow this extension to run in WebLibre.'
+ : 'This extension cannot be safely enabled.',
+ ),
+ value: addon.isEnabled,
+ onChanged: addonAsync.isLoading || !addon.canUserToggleEnabled
+ ? null
+ : (enabled) => ref
+ .read(addonDetailsProvider(addonId).notifier)
+ .setEnabled(enabled: enabled),
+ ),
+ SwitchListTile.adaptive(
+ title: const Text('Allow in Private Browsing'),
+ subtitle: const Text(
+ 'Let this extension run in private browsing tabs.',
+ ),
+ value: addon.isAllowedInPrivateBrowsing,
+ onChanged: addonAsync.isLoading
+ ? null
+ : (allowed) => ref
+ .read(addonDetailsProvider(addonId).notifier)
+ .setAllowedInPrivateBrowsing(allowed: allowed),
+ ),
+ SwitchListTile.adaptive(
+ title: const Text('Automatic updates'),
+ subtitle: Text(autoUpdateSubtitle),
+ value: addon.isAutoUpdateEnabled,
+ onChanged: canChangePerAddonAutoUpdate
+ ? (enabled) => ref
+ .read(addonDetailsProvider(addonId).notifier)
+ .setAutoUpdateEnabled(enabled: enabled)
+ : null,
+ ),
+ SwitchListTile.adaptive(
+ title: const Text('Pin to toolbar'),
+ subtitle: const Text(
+ 'Show this extension as an icon in the main tab bar.',
+ ),
+ value: isPinned,
+ onChanged: (pinned) {
+ ref
+ .read(pinnedAddonIdsProvider.notifier)
+ .setPinned(addonId, pinned: pinned);
+ },
+ ),
+ if (addon.hasOptionsPage)
+ ListTile(
+ leading: const Icon(Icons.settings_outlined),
+ title: const Text('Extension Settings'),
+ subtitle: Text(
+ addon.openOptionsPageInTab
+ ? 'Open the extension options page in a browser tab'
+ : 'Open the extension options page',
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => openAddonSettingsFlow(context, ref, addon),
+ ),
+ ListTile(
+ leading: const Icon(Icons.privacy_tip_outlined),
+ title: const Text('Permissions'),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => AddonPermissionsRoute(
+ addonId: addon.id,
+ ).push(context),
+ ),
+ ListTile(
+ leading: const Icon(Icons.delete_outline),
+ title: const Text('Remove Extension'),
+ textColor: theme.colorScheme.error,
+ iconColor: theme.colorScheme.error,
+ onTap: addonAsync.isLoading
+ ? null
+ : () async {
+ final confirmed = await _showConfirmUninstallDialog(
+ context,
+ addon,
+ );
+ if (confirmed != true || !context.mounted) return;
+
+ final displayName = addon.displayName;
+ await ref
+ .read(addonDetailsProvider(addonId).notifier)
+ .uninstall();
+ if (!context.mounted) return;
+
+ showInfoMessage(context, '$displayName removed');
+ Navigator.of(context).pop();
+ },
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+Future _showConfirmUninstallDialog(
+ BuildContext context,
+ AddonInfo addon,
+) {
+ return showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Remove extension?'),
+ content: Text('Remove ${addon.displayName} from WebLibre?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Remove'),
+ ),
+ ],
+ ),
+ );
+}
+
+class _UpdatesSection extends ConsumerWidget {
+ final String addonId;
+
+ const _UpdatesSection({required this.addonId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
+
+ final addon = ref.watch(addonDetailsProvider(addonId)).value;
+ if (addon == null) return const SizedBox.shrink();
+
+ final storeInfo = ref.watch(addonStoreInfoProvider(addonId)).value;
+ final updateAttempt = ref
+ .watch(lastAddonUpdateAttemptProvider(addonId))
+ .value;
+ final checking = ref.watch(addonUpdateCheckProvider(addonId)).isLoading;
+
+ final availableVersion = _displayAvailableVersion(addon, storeInfo);
+ final hasAvailableUpdate =
+ addon.installedVersion != null &&
+ availableVersion.isNotEmpty &&
+ addon.installedVersion != availableVersion;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Updates', style: theme.textTheme.titleMedium),
+ const SizedBox(height: 8),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(formatUpdateAttemptStatus(updateAttempt)),
+ const SizedBox(height: 8),
+ if (hasAvailableUpdate) ...[
+ Text(
+ 'Update available: ${addon.installedVersion} \u2192 $availableVersion',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.primary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 4),
+ ],
+ Text(
+ updateAttempt == null
+ ? 'No recent update attempt information is available yet.'
+ : 'Last checked: ${formatUpdateAttemptDate(updateAttempt)}',
+ style: theme.textTheme.bodySmall,
+ ),
+ const SizedBox(height: 12),
+ FilledButton.icon(
+ onPressed: checking
+ ? null
+ : () => _runUpdateCheck(context, ref, addonId),
+ icon: checking
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.system_update_alt),
+ label: Text(
+ checking ? 'Checking for Updates' : 'Check for Updates',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+String _displayAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
+ final latest = storeInfo?.latestVersion.trim();
+ return (latest != null && latest.isNotEmpty) ? latest : addon.version;
+}
+
+Future _runUpdateCheck(
+ BuildContext context,
+ WidgetRef ref,
+ String addonId,
+) async {
+ final outcome = await ref
+ .read(addonUpdateCheckProvider(addonId).notifier)
+ .resolveAvailableUpdate();
+
+ if (!context.mounted) return;
+
+ switch (outcome) {
+ case AddonUpdateOutcomeMissing():
+ return;
+ case AddonUpdateOutcomeUpToDate():
+ final result = await ref
+ .read(addonUpdateCheckProvider(addonId).notifier)
+ .triggerAndAwait();
+ if (!context.mounted) return;
+ _reportUpdateResult(context, result, fallback: 'No update available');
+ case AddonUpdateOutcomeAvailable(
+ addon: final fresh,
+ :final availableVersion,
+ ):
+ final confirmed = await _confirmUpdateDialog(
+ context,
+ fresh,
+ availableVersion,
+ );
+ if (confirmed != true || !context.mounted) return;
+
+ final result = await ref
+ .read(addonUpdateCheckProvider(addonId).notifier)
+ .triggerAndAwait();
+ if (!context.mounted) return;
+ _reportUpdateResult(context, result);
+ }
+}
+
+Future _confirmUpdateDialog(
+ BuildContext context,
+ AddonInfo addon,
+ String availableVersion,
+) {
+ return showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Update available'),
+ content: Text(
+ 'Update ${addon.displayName} from '
+ '${addon.installedVersion} to $availableVersion?',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Not now'),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Update'),
+ ),
+ ],
+ ),
+ );
+}
+
+void _reportUpdateResult(
+ BuildContext context,
+ AddonUpdateRunResult result, {
+ String? fallback,
+}) {
+ switch (result) {
+ case AddonUpdateRunDone(:final message):
+ final text = (message != null && message.isNotEmpty) ? message : fallback;
+ if (text != null) showInfoMessage(context, text);
+ case AddonUpdateRunNoRemoteSource():
+ showErrorMessage(
+ context,
+ 'This locally installed extension has no remote update source.',
+ );
+ case AddonUpdateRunFailed():
+ showErrorMessage(context, 'Failed to start update check.');
+ }
+}
+
+class _DescriptionCard extends StatelessWidget {
+ final AddonInfo addon;
+
+ const _DescriptionCard({required this.addon});
+
+ @override
+ Widget build(BuildContext context) {
+ final description = addon.description;
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ description.isNotEmpty ? description : 'No description provided.',
+ ),
+ ),
+ );
+ }
+}
+
+class _DetailsCard extends StatelessWidget {
+ final AddonInfo addon;
+
+ const _DetailsCard({required this.addon});
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Column(
+ children: [
+ if ((addon.authorName ?? '').isNotEmpty)
+ ListTile(
+ leading: const Icon(Icons.person_outline),
+ title: const Text('Author'),
+ subtitle: Text(addon.authorName!),
+ onTap: (addon.authorUrl ?? '').isEmpty
+ ? null
+ : () => launchUrl(Uri.parse(addon.authorUrl!)),
+ ),
+ ListTile(
+ leading: const Icon(Icons.tag_outlined),
+ title: const Text('Version'),
+ subtitle: Text(addon.installedVersion ?? addon.version),
+ ),
+ ListTile(
+ leading: const Icon(Icons.update_outlined),
+ title: const Text('Last Updated'),
+ subtitle: Text(formatAddonDate(addon.updatedAt)),
+ ),
+ if (addon.homepageUrl.isNotEmpty)
+ ListTile(
+ leading: const Icon(Icons.public),
+ title: const Text('Homepage'),
+ subtitle: Text(addon.homepageUrl),
+ trailing: const Icon(Icons.open_in_new),
+ onTap: () => launchUrl(Uri.parse(addon.homepageUrl)),
+ ),
+ if (addon.detailUrl.isNotEmpty)
+ ListTile(
+ leading: const Icon(Icons.storefront_outlined),
+ title: const Text('Addon Listing'),
+ subtitle: Text(addon.detailUrl),
+ trailing: const Icon(Icons.open_in_new),
+ onTap: () => launchUrl(Uri.parse(addon.detailUrl)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_internal_settings.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_internal_settings.dart
new file mode 100644
index 00000000..71c1822a
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_internal_settings.dart
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/addons/domain/providers.dart';
+import 'package:weblibre/features/addons/extensions/addon_info.dart';
+import 'package:weblibre/features/geckoview/domain/providers.dart';
+
+Future openAddonSettingsFlow(
+ BuildContext context,
+ WidgetRef ref,
+ AddonInfo addon,
+) async {
+ if (!addon.hasOptionsPage) {
+ await const AddonManagerRoute().push(context);
+ return;
+ }
+
+ if (addon.openOptionsPageInTab) {
+ final optionsPageUrl = addon.optionsPageUrl;
+ if (optionsPageUrl == null || optionsPageUrl.isEmpty) {
+ return;
+ }
+
+ await GeckoTabService().selectOrAddTabByUrl(
+ url: Uri.parse(optionsPageUrl),
+ ignoreFragment: true,
+ );
+
+ if (context.mounted) {
+ const BrowserRoute().go(context);
+ }
+ return;
+ }
+
+ await AddonInternalSettingsRoute(addonId: addon.id).push(context);
+}
+
+Future openAddonSettingsFlowById(
+ BuildContext context,
+ WidgetRef ref,
+ String addonId,
+) async {
+ final addon = await ref.read(addonServiceProvider).getAddonById(addonId);
+ if (addon == null) {
+ if (!context.mounted) return;
+
+ await const AddonManagerRoute().push(context);
+ return;
+ }
+
+ if (!context.mounted) return;
+
+ await openAddonSettingsFlow(context, ref, addon);
+}
+
+class AddonInternalSettingsScreen extends ConsumerWidget {
+ final String addonId;
+
+ const AddonInternalSettingsScreen({required this.addonId, super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonAsync = ref.watch(addonDetailsProvider(addonId));
+
+ final addon = addonAsync.value;
+ final optionsPageUrl = addon?.optionsPageUrl;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(
+ addon == null
+ ? 'Extension Settings'
+ : '${addon.displayName} Settings',
+ ),
+ ),
+ body: switch (addonAsync) {
+ AsyncLoading() when addon == null => const Center(
+ child: CircularProgressIndicator(),
+ ),
+ AsyncError(:final error) => Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ 'Failed to load extension settings: $error',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ _
+ when addon == null ||
+ optionsPageUrl == null ||
+ optionsPageUrl.isEmpty =>
+ const Center(
+ child: Text('This extension does not expose a settings page.'),
+ ),
+ _ => _AddonSettingsPlatformView(optionsPageUrl: optionsPageUrl),
+ },
+ );
+ }
+}
+
+class _AddonSettingsPlatformView extends StatelessWidget {
+ final String optionsPageUrl;
+
+ const _AddonSettingsPlatformView({required this.optionsPageUrl});
+
+ @override
+ Widget build(BuildContext context) {
+ return PlatformViewLink(
+ viewType: 'eu.weblibre/addon_settings',
+ surfaceFactory: (context, controller) {
+ return AndroidViewSurface(
+ controller: controller as AndroidViewController,
+ gestureRecognizers: const >{},
+ hitTestBehavior: PlatformViewHitTestBehavior.opaque,
+ );
+ },
+ onCreatePlatformView: (params) {
+ final controller = PlatformViewsService.initExpensiveAndroidView(
+ id: params.id,
+ viewType: 'eu.weblibre/addon_settings',
+ layoutDirection: TextDirection.ltr,
+ creationParams: {'optionsPageUrl': optionsPageUrl},
+ creationParamsCodec: const StandardMessageCodec(),
+ );
+ controller.addOnPlatformViewCreatedListener(
+ params.onPlatformViewCreated,
+ );
+ unawaited(controller.create());
+ return controller;
+ },
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart
new file mode 100644
index 00000000..02b2a3d9
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart
@@ -0,0 +1,328 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/addons/domain/providers.dart';
+import 'package:weblibre/features/addons/extensions/addon_info.dart';
+import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
+import 'package:weblibre/utils/ui_helper.dart';
+
+class AddonManagerScreen extends ConsumerWidget {
+ const AddonManagerScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonsAsync = ref.watch(addonListProvider);
+
+ Future refresh() => ref.read(addonListProvider.notifier).refresh();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Extensions'),
+ actions: [
+ IconButton(
+ onPressed: addonsAsync.isLoading ? null : refresh,
+ icon: const Icon(Icons.refresh),
+ ),
+ _TriggerAllUpdatesButton(
+ enabled: addonsAsync.maybeWhen(
+ data: (addons) =>
+ addons.any((a) => a.isInstalled && a.isSupported),
+ orElse: () => false,
+ ),
+ ),
+ ],
+ ),
+ body: addonsAsync.when(
+ skipLoadingOnReload: true,
+ skipError: true,
+ data: (addons) => RefreshIndicator(
+ onRefresh: refresh,
+ child: _AddonList(addons: addons),
+ ),
+ error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
+ loading: () => const Center(child: CircularProgressIndicator()),
+ ),
+ );
+ }
+}
+
+class _TriggerAllUpdatesButton extends ConsumerWidget {
+ final bool enabled;
+
+ const _TriggerAllUpdatesButton({required this.enabled});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final busy = ref.watch(
+ bulkAddonUpdateProvider.select((value) => value.isLoading),
+ );
+
+ return IconButton(
+ onPressed: enabled && !busy
+ ? () async {
+ await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
+ if (!context.mounted) return;
+ showInfoMessage(
+ context,
+ 'Background update checks started for installed extensions',
+ );
+ }
+ : null,
+ icon: busy
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.system_update_alt),
+ tooltip: 'Check all installed extensions for updates',
+ );
+ }
+}
+
+class _AddonList extends StatelessWidget {
+ final List addons;
+
+ const _AddonList({required this.addons});
+
+ @override
+ Widget build(BuildContext context) {
+ final enabled = addons
+ .where((a) => a.isInstalled && a.isSupported && a.isEnabled)
+ .toList();
+ final disabled = addons
+ .where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
+ .toList();
+ final recommended = addons.where((a) => !a.isInstalled).toList();
+ final unsupported = addons
+ .where((a) => a.isInstalled && !a.isSupported)
+ .toList();
+
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ const Card(
+ child: ListTile(
+ leading: Icon(Icons.info_outline),
+ title: Text('Addon updates run in the background'),
+ subtitle: Text(
+ 'Use each extension detail screen to view its last update result or trigger a manual check.',
+ ),
+ ),
+ ),
+ if (enabled.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ const _Section(title: 'Enabled'),
+ for (final addon in enabled) _AddonCard(addon: addon),
+ ],
+ if (disabled.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ const _Section(title: 'Disabled'),
+ for (final addon in disabled) _AddonCard(addon: addon),
+ ],
+ if (recommended.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ const _Section(title: 'Available'),
+ for (final addon in recommended)
+ _AddonCard(
+ addon: addon,
+ action: _InstallAction(addon: addon),
+ ),
+ ],
+ if (unsupported.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ const _Section(title: 'Unsupported'),
+ for (final addon in unsupported)
+ _AddonCard(
+ addon: addon,
+ action: _UninstallAction(addon: addon),
+ ),
+ ],
+ if (addons.isEmpty)
+ const Padding(
+ padding: EdgeInsets.only(top: 48),
+ child: Center(child: Text('No extensions available right now.')),
+ ),
+ ],
+ );
+ }
+}
+
+class _InstallAction extends ConsumerWidget {
+ final AddonInfo addon;
+
+ const _InstallAction({required this.addon});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
+ return FilledButton(
+ onPressed: busy
+ ? null
+ : () async {
+ await ref.read(addonListProvider.notifier).install(addon);
+ if (!context.mounted) return;
+ showInfoMessage(context, '${addon.displayName} installed');
+ },
+ child: const Text('Install'),
+ );
+ }
+}
+
+class _UninstallAction extends ConsumerWidget {
+ final AddonInfo addon;
+
+ const _UninstallAction({required this.addon});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
+ return IconButton(
+ tooltip: 'Remove extension',
+ onPressed: busy
+ ? null
+ : () async {
+ await ref.read(addonListProvider.notifier).uninstall(addon);
+ if (!context.mounted) return;
+ showInfoMessage(context, '${addon.displayName} removed');
+ },
+ icon: const Icon(Icons.delete_outline),
+ );
+ }
+}
+
+class _Section extends StatelessWidget {
+ final String title;
+
+ const _Section({required this.title});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(title, style: Theme.of(context).textTheme.titleMedium),
+ );
+ }
+}
+
+class _AddonCard extends ConsumerWidget {
+ final AddonInfo addon;
+ final Widget? action;
+
+ const _AddonCard({required this.addon, this.action});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: InkWell(
+ onTap: busy
+ ? null
+ : () => AddonDetailsRoute(addonId: addon.id).push(context),
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ AddonIconView(addon: addon),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ addon.displayName,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ if ((addon.summary ?? '').isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(addon.summary!),
+ ],
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ if (addon.isAllowedInPrivateBrowsing)
+ const Chip(label: Text('Private Browsing')),
+ if (addon.ratingAverage != null)
+ Chip(
+ avatar: const Icon(Icons.star, size: 16),
+ label: Text(
+ addon.ratingAverage!.toStringAsFixed(1),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ action ?? const Icon(Icons.chevron_right),
+ ],
+ ),
+ if (addon.statusBannerMessage != null) ...[
+ const SizedBox(height: 12),
+ AddonStatusBanner(addon: addon),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _AddonLoadError extends StatelessWidget {
+ final Object? error;
+ final VoidCallback onRetry;
+
+ const _AddonLoadError({required this.error, required this.onRetry});
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error_outline, size: 40),
+ const SizedBox(height: 12),
+ const Text('Failed to load extensions'),
+ const SizedBox(height: 8),
+ Text(error.toString(), textAlign: TextAlign.center),
+ const SizedBox(height: 16),
+ FilledButton(onPressed: onRetry, child: const Text('Retry')),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_permissions.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_permissions.dart
new file mode 100644
index 00000000..58b85bc1
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_permissions.dart
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:weblibre/features/addons/domain/providers.dart';
+
+const _permissionsLearnMoreUrl =
+ 'https://support.mozilla.org/kb/permission-request-messages-firefox-extensions';
+
+class AddonPermissionsScreen extends ConsumerWidget {
+ final String addonId;
+
+ const AddonPermissionsScreen({required this.addonId, super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonAsync = ref.watch(addonDetailsProvider(addonId));
+ final addon = addonAsync.value;
+
+ final permissions = addon?.translatedPermissions.toList() ?? [];
+ permissions.sort();
+
+ final dataCollection =
+ addon?.translatedRequiredDataCollectionPermissions.toList() ??
+ [];
+ dataCollection.sort();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(
+ addon == null
+ ? 'Extension Permissions'
+ : '${addon.displayName} Permissions',
+ ),
+ ),
+ body: switch (addonAsync) {
+ AsyncLoading() when addon == null => const Center(
+ child: CircularProgressIndicator(),
+ ),
+ AsyncError(:final error) => Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ 'Failed to load extension permissions: $error',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ _ when addon == null => const Center(
+ child: Text('This extension could not be found.'),
+ ),
+ _ => ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ if (permissions.isEmpty && dataCollection.isEmpty)
+ const Card(
+ child: ListTile(
+ leading: Icon(Icons.verified_user_outlined),
+ title: Text('No special permissions listed'),
+ subtitle: Text(
+ 'This extension does not currently expose any translated permission details.',
+ ),
+ ),
+ ),
+ if (permissions.isNotEmpty) ...[
+ Text(
+ 'Permissions',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 8),
+ Card(
+ child: Column(
+ children: [
+ for (final permission in permissions)
+ ListTile(
+ leading: const Icon(Icons.check_circle_outline),
+ title: Text(permission),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ ],
+ if (dataCollection.isNotEmpty) ...[
+ Text(
+ 'Required Data Collection',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 8),
+ Card(
+ child: Column(
+ children: [
+ for (final permission in dataCollection)
+ ListTile(
+ leading: const Icon(Icons.data_usage_outlined),
+ title: Text(permission),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ ],
+ FilledButton.icon(
+ onPressed: () async {
+ await launchUrl(Uri.parse(_permissionsLearnMoreUrl));
+ },
+ icon: const Icon(Icons.open_in_new),
+ label: const Text('Learn More'),
+ ),
+ ],
+ ),
+ },
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/widgets/addon_ui.dart b/apps/weblibre/lib/features/addons/presentation/widgets/addon_ui.dart
new file mode 100644
index 00000000..619f89d2
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/widgets/addon_ui.dart
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:intl/intl.dart';
+import 'package:weblibre/features/addons/extensions/addon_info.dart';
+
+class AddonIconView extends StatelessWidget {
+ final AddonInfo addon;
+ final double size;
+
+ const AddonIconView({required this.addon, this.size = 40, super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final bytes = addon.icon;
+ final borderRadius = BorderRadius.circular(12);
+
+ if (bytes != null && bytes.isNotEmpty) {
+ return ClipRRect(
+ borderRadius: borderRadius,
+ child: Image.memory(
+ bytes,
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => _FallbackIcon(size: size),
+ ),
+ );
+ }
+
+ return _FallbackIcon(size: size);
+ }
+}
+
+class _FallbackIcon extends StatelessWidget {
+ final double size;
+
+ const _FallbackIcon({required this.size});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Container(
+ width: size,
+ height: size,
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
+ );
+ }
+}
+
+class AddonStatusBanner extends StatelessWidget {
+ final AddonInfo addon;
+
+ const AddonStatusBanner({required this.addon, super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final message = addon.statusBannerMessage;
+ if (message == null) {
+ return const SizedBox.shrink();
+ }
+
+ final isWarning = addon.disabledReason == AddonDisabledReason.softBlocked;
+ final theme = Theme.of(context);
+ final background = isWarning
+ ? theme.colorScheme.tertiaryContainer
+ : theme.colorScheme.errorContainer;
+ final foreground = isWarning
+ ? theme.colorScheme.onTertiaryContainer
+ : theme.colorScheme.onErrorContainer;
+ final icon = isWarning ? Icons.warning_amber_rounded : Icons.error_outline;
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: background,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(icon, color: foreground, size: 20),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ message,
+ style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+String formatAddonDate(String raw) {
+ final parsed = DateTime.tryParse(raw);
+ if (parsed == null) {
+ return raw.isEmpty ? 'Unknown' : raw;
+ }
+
+ return DateFormat.yMMMd().format(parsed.toLocal());
+}
+
+String formatUpdateAttemptDate(AddonUpdateAttemptInfo attempt) {
+ final date = DateTime.fromMillisecondsSinceEpoch(
+ attempt.dateMillisecondsSinceEpoch,
+ ).toLocal();
+ return DateFormat.yMMMd().add_jm().format(date);
+}
+
+String formatUpdateAttemptStatus(AddonUpdateAttemptInfo? attempt) {
+ return switch (attempt?.status) {
+ AddonUpdateStatus.successfullyUpdated =>
+ attempt?.message?.isNotEmpty == true
+ ? attempt!.message!
+ : 'Updated successfully',
+ AddonUpdateStatus.noUpdateAvailable => 'No update available',
+ AddonUpdateStatus.notInstalled => 'Extension not installed',
+ AddonUpdateStatus.error =>
+ attempt?.message?.isNotEmpty == true
+ ? 'Update failed: ${attempt!.message}'
+ : 'Update failed',
+ null => 'No update checks recorded yet',
+ };
+}
diff --git a/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart b/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart
new file mode 100644
index 00000000..fa048d8d
--- /dev/null
+++ b/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/addons/domain/providers.dart';
+import 'package:weblibre/features/geckoview/domain/providers.dart';
+import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
+
+class PinnedAddonBar extends ConsumerWidget {
+ const PinnedAddonBar({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final pinnedIds = ref.watch(pinnedAddonIdsProvider);
+ if (pinnedIds.isEmpty) return const SizedBox.shrink();
+
+ final extensions = ref.watch(
+ webExtensionsStateProvider(
+ WebExtensionActionType.browser,
+ ).select((value) => value.values.toList()),
+ );
+
+ final pinned = extensions
+ .where((e) => pinnedIds.contains(e.extensionId))
+ .toList();
+ if (pinned.isEmpty) return const SizedBox.shrink();
+
+ return Padding(
+ padding: const EdgeInsets.only(right: 6.0),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (final extension in pinned)
+ InkResponse(
+ radius: 22,
+ onTap: () async {
+ await ref
+ .read(addonServiceProvider)
+ .invokeAddonAction(
+ extension.extensionId,
+ WebExtensionActionType.browser,
+ );
+ },
+ onLongPress: () async {
+ await AddonDetailsRoute(
+ addonId: extension.extensionId,
+ ).push(context);
+ },
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6.0,
+ vertical: 8.0,
+ ),
+ child: ExtensionBadgeIcon(extension),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
index 8bee4262..0bed280c 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
@override
FutureOr build() async {
- final prefs =
- await GeckoPrefService().getPrefs([_signatureRequiredPref]);
+ final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
final pref = prefs[_signatureRequiredPref];
final allowUnsigned = pref?.value == false;
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
}
}
+@Riverpod(keepAlive: true)
+class AddonAutoUpdate extends _$AddonAutoUpdate {
+ Future setEnabled({required bool enabled}) async {
+ final service = ref.read(addonServiceProvider);
+ await service.setAddonAutoUpdateEnabled(enabled: enabled);
+
+ state = AsyncData(enabled);
+ }
+
+ @override
+ FutureOr build() {
+ return ref.read(addonServiceProvider).isAddonAutoUpdateEnabled();
+ }
+}
+
@Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService {
Future getAddonXpiUrl(String guid) async {
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
index de3dfba0..69942bb7 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
@@ -54,6 +54,50 @@ abstract class _$AllowUnsignedExtensions extends $AsyncNotifier {
}
}
+@ProviderFor(AddonAutoUpdate)
+final addonAutoUpdateProvider = AddonAutoUpdateProvider._();
+
+final class AddonAutoUpdateProvider
+ extends $AsyncNotifierProvider {
+ AddonAutoUpdateProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'addonAutoUpdateProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$addonAutoUpdateHash();
+
+ @$internal
+ @override
+ AddonAutoUpdate create() => AddonAutoUpdate();
+}
+
+String _$addonAutoUpdateHash() => r'89791e8b771da715b068bbdbe5c3c24a3dad4194';
+
+abstract class _$AddonAutoUpdate extends $AsyncNotifier {
+ FutureOr build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, bool>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, bool>,
+ AsyncValue,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
+
@ProviderFor(BrowserAddonService)
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
index 749dfc30..696bdadf 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
.installFromFile(selectedFile.value!);
if (context.mounted) {
- showInfoMessage(context, 'Extension installed successfully');
+ showInfoMessage(
+ context,
+ 'Extension installed. Automatic updates are disabled for this local version.',
+ );
context.pop(true);
}
} catch (e) {
@@ -149,6 +152,11 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
],
),
),
+ const SizedBox(height: 12),
+ Text(
+ 'Extensions installed from a local XPI stay pinned to that version and will not update automatically.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
if (errorMessage.value != null) ...[
const SizedBox(height: 8),
Container(
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
index 38936f20..49ffbc8f 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
@@ -305,6 +306,18 @@ class BrowserScreen extends HookConsumerWidget {
},
);
+ final addonService = ref.watch(addonServiceProvider);
+ useOnStreamChange(
+ addonService.popupStream,
+ onData: (event) async {
+ await showAddonPopupBottomSheet(
+ context,
+ extensionId: event.extensionId,
+ extensionName: event.extensionName,
+ );
+ },
+ );
+
useOnAppLifecycleStateChange((previous, current) {
switch (current) {
case AppLifecycleState.resumed:
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart
new file mode 100644
index 00000000..7d94d0c4
--- /dev/null
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+
+const _viewType = 'eu.weblibre/addon_popup';
+
+Future showAddonPopupBottomSheet(
+ BuildContext context, {
+ required String extensionId,
+ required String extensionName,
+}) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
+ ),
+ builder: (context) => _AddonPopupSheet(
+ extensionId: extensionId,
+ extensionName: extensionName,
+ ),
+ );
+}
+
+class _AddonPopupSheet extends StatelessWidget {
+ final String extensionId;
+ final String extensionName;
+
+ const _AddonPopupSheet({
+ required this.extensionId,
+ required this.extensionName,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return DraggableScrollableSheet(
+ initialChildSize: 0.65,
+ minChildSize: 0.3,
+ maxChildSize: 0.95,
+ expand: false,
+ builder: (context, scrollController) {
+ return Column(
+ children: [
+ Container(
+ margin: const EdgeInsets.only(top: 12, bottom: 8),
+ height: 4,
+ width: 40,
+ decoration: BoxDecoration(
+ color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const Divider(height: 1),
+ Expanded(child: _AddonPopupPlatformView(extensionId: extensionId)),
+ ],
+ );
+ },
+ );
+ }
+}
+
+class _AddonPopupPlatformView extends StatelessWidget {
+ final String extensionId;
+
+ const _AddonPopupPlatformView({required this.extensionId});
+
+ @override
+ Widget build(BuildContext context) {
+ return PlatformViewLink(
+ viewType: _viewType,
+ surfaceFactory: (context, controller) {
+ return AndroidViewSurface(
+ controller: controller as AndroidViewController,
+ gestureRecognizers: const >{
+ Factory(EagerGestureRecognizer.new),
+ },
+ hitTestBehavior: PlatformViewHitTestBehavior.opaque,
+ );
+ },
+ onCreatePlatformView: (params) {
+ final controller = PlatformViewsService.initExpensiveAndroidView(
+ id: params.id,
+ viewType: _viewType,
+ layoutDirection: TextDirection.ltr,
+ creationParams: {'extensionId': extensionId},
+ creationParamsCodec: const StandardMessageCodec(),
+ );
+ controller.addOnPlatformViewCreatedListener(
+ params.onPlatformViewCreated,
+ );
+ unawaited(controller.create());
+ return controller;
+ },
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
index 3cf6f324..7b06732a 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
@@ -36,6 +36,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/providers/persisted_bool.dart';
import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
+ final rootContext = Navigator.of(context, rootNavigator: true).context;
Future openExtensionSettings(String extensionId) async {
Navigator.pop(context);
- await addonService.startAddonSettingsActivity(extensionId);
+ await openAddonSettingsFlowById(rootContext, ref, extensionId);
}
return _buildMenuCard(
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
icon: MdiIcons.puzzleEdit,
onTap: () async {
Navigator.pop(context);
- await addonService.startAddonManagerActivity();
+ await const AddonManagerRoute().push(rootContext);
},
),
_buildSubTile(
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
index c8211114..78c89dd2 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
@@ -27,6 +27,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
+import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
: const AppBarTitle()
: null,
actions: [
+ const PinnedAddonBar(),
if (isSmallWebMode)
ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
diff --git a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart
index 3404e347..32272d59 100644
--- a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart
+++ b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'Extensions'),
+ _ManageExtensionsTile(),
_InstallLocalAddonTile(),
_AddonCollectionTile(),
+ SettingSection(name: 'Updates'),
+ _AutoUpdateTile(),
SettingSection(name: 'Security'),
_AllowUnsignedExtensionsTile(),
],
@@ -59,6 +62,34 @@ class ExtensionsSettingsScreen extends StatelessWidget {
}
}
+class _ManageExtensionsTile extends StatelessWidget {
+ const _ManageExtensionsTile();
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomListTile(
+ title: 'Manage Extensions',
+ subtitle:
+ 'Browse installed, disabled, available, and unsupported extensions',
+ prefix: Padding(
+ padding: const EdgeInsets.only(right: 16.0),
+ child: Icon(
+ MdiIcons.puzzleEdit,
+ size: 24,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ suffix: FilledButton.icon(
+ onPressed: () async {
+ await const AddonManagerRoute().push(context);
+ },
+ icon: const Icon(Icons.open_in_new),
+ label: const Text('Open'),
+ ),
+ );
+ }
+}
+
class _InstallLocalAddonTile extends StatelessWidget {
const _InstallLocalAddonTile();
@@ -113,6 +144,45 @@ class _AddonCollectionTile extends StatelessWidget {
}
}
+class _AutoUpdateTile extends ConsumerWidget {
+ const _AutoUpdateTile();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final autoUpdate = ref.watch(addonAutoUpdateProvider);
+
+ return autoUpdate.when(
+ data: (enabled) => SwitchListTile.adaptive(
+ title: const Text('Automatic updates'),
+ subtitle: const Text(
+ 'Automatically check for and install extension updates every 12 hours',
+ ),
+ secondary: const Icon(Icons.system_update_alt),
+ value: enabled,
+ onChanged: (value) async {
+ await ref
+ .read(addonAutoUpdateProvider.notifier)
+ .setEnabled(enabled: value);
+ },
+ ),
+ loading: () => const SwitchListTile.adaptive(
+ title: Text('Automatic updates'),
+ subtitle: Text(
+ 'Automatically check for and install extension updates every 12 hours',
+ ),
+ secondary: Icon(Icons.system_update_alt),
+ value: true,
+ onChanged: null,
+ ),
+ error: (error, stack) => ListTile(
+ leading: const Icon(Icons.error_outline),
+ title: const Text('Automatic updates'),
+ subtitle: Text('Failed to load: $error'),
+ ),
+ );
+ }
+}
+
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
const _AllowUnsignedExtensionsTile();
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
- return Column(
- children: [
- SwitchListTile.adaptive(
- title: const Text('Allow unsigned extensions'),
- subtitle: const Text(
- 'Unsigned extensions have not been verified by Mozilla',
- ),
- secondary: const Icon(Icons.extension_off),
- value: allowUnsigned.value ?? false,
- onChanged: allowUnsigned.isLoading
- ? null
- : (value) async {
- if (value) {
- final confirmed =
- await _showAllowUnsignedConfirmationDialog(context);
- if (confirmed != true) return;
- }
- await ref
- .read(allowUnsignedExtensionsProvider.notifier)
- .setAllowUnsigned(allow: value);
- },
- ),
- if (allowUnsigned.value == true)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0),
- child: Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: Theme.of(
+ return allowUnsigned.when(
+ data: (allowed) => Column(
+ children: [
+ SwitchListTile.adaptive(
+ title: const Text('Allow unsigned extensions'),
+ subtitle: const Text(
+ 'Unsigned extensions have not been verified by Mozilla',
+ ),
+ secondary: const Icon(Icons.extension_off),
+ value: allowed,
+ onChanged: (value) async {
+ if (value) {
+ final confirmed = await _showAllowUnsignedConfirmationDialog(
context,
- ).colorScheme.errorContainer.withValues(alpha: 0.5),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: Theme.of(context).colorScheme.error),
- ),
- child: Row(
- children: [
- Icon(
- Icons.warning_amber,
+ );
+ if (confirmed != true) return;
+ }
+ await ref
+ .read(allowUnsignedExtensionsProvider.notifier)
+ .setAllowUnsigned(allow: value);
+ },
+ ),
+ if (allowed)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ child: Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).colorScheme.errorContainer.withValues(alpha: 0.5),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
color: Theme.of(context).colorScheme.error,
- size: 20,
),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- 'Only install unsigned extensions from sources you trust. '
- 'They may contain malicious code.',
- style: TextStyle(
- color: Theme.of(context).colorScheme.onErrorContainer,
- fontSize: 12,
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.warning_amber,
+ color: Theme.of(context).colorScheme.error,
+ size: 20,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Only install unsigned extensions from sources you trust. '
+ 'They may contain malicious code.',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onErrorContainer,
+ fontSize: 12,
+ ),
),
),
- ),
- ],
+ ],
+ ),
),
),
- ),
- ],
+ ],
+ ),
+ loading: () => const SwitchListTile.adaptive(
+ title: Text('Allow unsigned extensions'),
+ subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
+ secondary: Icon(Icons.extension_off),
+ value: false,
+ onChanged: null,
+ ),
+ error: (error, stack) => ListTile(
+ leading: const Icon(Icons.error_outline),
+ title: const Text('Allow unsigned extensions'),
+ subtitle: Text('Failed to load: $error'),
+ ),
);
}
}
@@ -262,4 +347,3 @@ class _AllowUnsignedConfirmationDialog extends HookWidget {
);
}
}
-
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddOnSettingsPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddOnSettingsPlatformView.kt
new file mode 100644
index 00000000..efd4c081
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddOnSettingsPlatformView.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+package eu.weblibre.flutter_mozilla_components
+
+import android.app.Activity
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.fragment.app.FragmentActivity
+import eu.weblibre.flutter_mozilla_components.addons.FlutterAddonSettingsFragment
+import io.flutter.plugin.common.StandardMessageCodec
+import io.flutter.plugin.platform.PlatformView
+import io.flutter.plugin.platform.PlatformViewFactory
+
+private const val OPTIONS_PAGE_URL_KEY = "optionsPageUrl"
+
+class AddonSettingsViewFactory(
+ private val activityProvider: () -> Activity?,
+) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
+ override fun create(context: Context?, id: Int, args: Any?): PlatformView {
+ val activity = activityProvider()
+ ?: throw IllegalStateException("No activity available when creating AddonSettingsView")
+ val optionsPageUrl = (args as? Map<*, *>)?.get(OPTIONS_PAGE_URL_KEY) as? String
+ ?: throw IllegalArgumentException("Missing optionsPageUrl creation param")
+
+ return NativeAddonSettingsView(activity, optionsPageUrl)
+ }
+}
+
+private class NativeAddonSettingsView(
+ activity: Activity,
+ private val optionsPageUrl: String,
+) : PlatformView {
+ private val fragmentActivity = activity as? FragmentActivity
+ ?: throw IllegalStateException("Addon settings view requires a FragmentActivity host")
+ private val containerId = View.generateViewId()
+ private val fragmentTag = "addon_settings_$containerId"
+ private val container: FrameLayout = FrameLayout(activity).apply {
+ id = containerId
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ private val attachStateListener = object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(view: View) {
+ container.removeOnAttachStateChangeListener(this)
+ container.post { attachFragment() }
+ }
+
+ override fun onViewDetachedFromWindow(view: View) = Unit
+ }
+
+ override fun onFlutterViewAttached(flutterView: View) {
+ super.onFlutterViewAttached(flutterView)
+
+ if (container.isAttachedToWindow) {
+ container.post { attachFragment() }
+ } else {
+ container.removeOnAttachStateChangeListener(attachStateListener)
+ container.addOnAttachStateChangeListener(attachStateListener)
+ }
+ }
+
+ override fun getView(): View = container
+
+ override fun dispose() {
+ val fm = fragmentActivity.supportFragmentManager
+ if (!fragmentActivity.isFinishing && !fragmentActivity.isDestroyed && !fm.isStateSaved) {
+ fm.findFragmentByTag(fragmentTag)?.let { fragment ->
+ fm.beginTransaction().remove(fragment).commitNowAllowingStateLoss()
+ }
+ }
+ }
+
+ private fun attachFragment() {
+ if (fragmentActivity.isFinishing || fragmentActivity.isDestroyed) {
+ return
+ }
+
+ val fm = fragmentActivity.supportFragmentManager
+ if (fm.isStateSaved) {
+ return
+ }
+
+ if (fragmentActivity.findViewById(containerId) == null) {
+ return
+ }
+
+ val existingFragment = fm.findFragmentByTag(fragmentTag)
+ if (existingFragment is FlutterAddonSettingsFragment) {
+ return
+ }
+
+ fm.beginTransaction()
+ .replace(containerId, FlutterAddonSettingsFragment.create(optionsPageUrl), fragmentTag)
+ .commitNow()
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddonPopupPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddonPopupPlatformView.kt
new file mode 100644
index 00000000..0df06aad
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/AddonPopupPlatformView.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+package eu.weblibre.flutter_mozilla_components
+
+import android.app.Activity
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.fragment.app.FragmentActivity
+import eu.weblibre.flutter_mozilla_components.addons.FlutterAddonPopupFragment
+import io.flutter.plugin.common.StandardMessageCodec
+import io.flutter.plugin.platform.PlatformView
+import io.flutter.plugin.platform.PlatformViewFactory
+
+private const val EXTENSION_ID_KEY = "extensionId"
+
+class AddonPopupViewFactory(
+ private val activityProvider: () -> Activity?,
+) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
+ override fun create(context: Context?, id: Int, args: Any?): PlatformView {
+ val activity = activityProvider()
+ ?: throw IllegalStateException("No activity available when creating AddonPopupView")
+ val extensionId = (args as? Map<*, *>)?.get(EXTENSION_ID_KEY) as? String
+ ?: throw IllegalArgumentException("Missing extensionId creation param")
+
+ return NativeAddonPopupView(activity, extensionId)
+ }
+}
+
+private class NativeAddonPopupView(
+ activity: Activity,
+ private val extensionId: String,
+) : PlatformView {
+ private val fragmentActivity = activity as? FragmentActivity
+ ?: throw IllegalStateException("Addon popup view requires a FragmentActivity host")
+ private val containerId = View.generateViewId()
+ private val fragmentTag = "addon_popup_$containerId"
+ private val container: FrameLayout = FrameLayout(activity).apply {
+ id = containerId
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ private val attachStateListener = object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(view: View) {
+ container.removeOnAttachStateChangeListener(this)
+ container.post { attachFragment() }
+ }
+
+ override fun onViewDetachedFromWindow(view: View) = Unit
+ }
+
+ override fun onFlutterViewAttached(flutterView: View) {
+ super.onFlutterViewAttached(flutterView)
+
+ if (container.isAttachedToWindow) {
+ container.post { attachFragment() }
+ } else {
+ container.removeOnAttachStateChangeListener(attachStateListener)
+ container.addOnAttachStateChangeListener(attachStateListener)
+ }
+ }
+
+ override fun getView(): View = container
+
+ override fun dispose() {
+ val fm = fragmentActivity.supportFragmentManager
+ if (!fragmentActivity.isFinishing && !fragmentActivity.isDestroyed && !fm.isStateSaved) {
+ fm.findFragmentByTag(fragmentTag)?.let { fragment ->
+ fm.beginTransaction().remove(fragment).commitNowAllowingStateLoss()
+ }
+ }
+ }
+
+ private fun attachFragment() {
+ if (fragmentActivity.isFinishing || fragmentActivity.isDestroyed) {
+ return
+ }
+
+ val fm = fragmentActivity.supportFragmentManager
+ if (fm.isStateSaved) {
+ return
+ }
+
+ if (fragmentActivity.findViewById(containerId) == null) {
+ return
+ }
+
+ val existingFragment = fm.findFragmentByTag(fragmentTag)
+ if (existingFragment is FlutterAddonPopupFragment) {
+ return
+ }
+
+ fm.beginTransaction()
+ .replace(containerId, FlutterAddonPopupFragment.create(extensionId), fragmentTag)
+ .commitNow()
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
index 549fcefd..6e38968c 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
@@ -22,7 +22,6 @@ import androidx.annotation.CallSuper
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
-import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
@@ -555,14 +554,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
protected open fun onEngineSetupComplete() {}
private fun openPopup(webExtensionState: WebExtensionState) {
- val intent = Intent(
- components.profileApplicationContext,
- WebExtensionActionPopupActivity::class.java
- )
- intent.putExtra("web_extension_id", webExtensionState.id)
- intent.putExtra("web_extension_name", webExtensionState.name)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(intent)
+ components.addonEvents.onWebExtensionPopupRequested(
+ webExtensionState.id,
+ webExtensionState.name ?: "",
+ ) {}
}
@CallSuper
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
index 0fc4a877..30f46e99 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
@@ -21,6 +21,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
+import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
@@ -49,7 +50,7 @@ import java.util.concurrent.TimeUnit
private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 // 14 days
private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST =
"__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid"
-
+
object GlobalComponents {
private var _components: Components? = null
private var currentMode: ComponentsMode? = null
@@ -268,7 +269,23 @@ object GlobalComponents {
},
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
onExtensionsLoaded = { extensions ->
- newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
+ val addonPrefs = AddonPrefs.get(applicationContext)
+ val autoUpdateEnabled =
+ addonPrefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
+ val autoUpdateDisabledAddonIds =
+ addonPrefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet())
+ ?: emptySet()
+ val localFileAddonIds =
+ addonPrefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet())
+ ?: emptySet()
+ if (autoUpdateEnabled) {
+ newComponents.core.addonUpdater.registerForFutureUpdates(
+ extensions.filterNot { extension ->
+ autoUpdateDisabledAddonIds.contains(extension.id) ||
+ localFileAddonIds.contains(extension.id)
+ },
+ )
+ }
newComponents.core.supportedAddonsChecker.registerForChecks()
},
)
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt
deleted file mode 100644
index 781e68a3..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.text.method.LinkMovementMethod
-import android.view.View
-import android.widget.RatingBar
-import android.widget.TextView
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.text.HtmlCompat
-import mozilla.components.feature.addons.R as MozComp
-import mozilla.components.feature.addons.Addon
-import mozilla.components.feature.addons.ui.translateDescription
-import mozilla.components.feature.addons.ui.translateName
-import mozilla.components.support.utils.ext.getParcelableExtraCompat
-import eu.weblibre.flutter_mozilla_components.R
-import java.text.DateFormat
-import java.text.SimpleDateFormat
-import java.util.Locale
-
-/**
- * An activity to show the details of an add-on.
- */
-class AddonDetailsActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_on_details)
- val addon = requireNotNull(
- intent.getParcelableExtraCompat("add_on", Addon::class.java),
- )
- bind(addon)
- }
-
- private fun bind(addon: Addon) {
- title = addon.translateName(this)
-
- bindDetails(addon)
-
- bindAuthor(addon)
-
- bindVersion(addon)
-
- bindLastUpdated(addon)
-
- bindWebsite(addon)
-
- bindRating(addon)
- }
-
- private fun bindRating(addon: Addon) {
- addon.rating?.let {
- val ratingView = findViewById(R.id.rating_view)
- val userCountView = findViewById(R.id.users_count)
-
- val ratingContentDescription = getString(MozComp.string.mozac_feature_addons_rating_content_description_2)
- ratingView.contentDescription = String.format(ratingContentDescription, it.average)
- ratingView.rating = it.average
-
- userCountView.text = getFormattedAmount(it.reviews)
- }
- }
-
- private fun bindWebsite(addon: Addon) {
- findViewById(R.id.home_page_text).setOnClickListener {
- val intent =
- Intent(Intent.ACTION_VIEW).setData(Uri.parse(addon.homepageUrl))
- startActivity(intent)
- }
- }
-
- private fun bindLastUpdated(addon: Addon) {
- val lastUpdatedView = findViewById(R.id.last_updated_text)
- lastUpdatedView.text = formatDate(addon.updatedAt)
- }
-
- private fun bindVersion(addon: Addon) {
- val versionView = findViewById(R.id.version_text)
- versionView.text = addon.version
- }
-
- private fun bindAuthor(addon: Addon) {
- val authorsView = findViewById(R.id.author_text)
-
- authorsView.text = addon.author?.name.orEmpty()
- }
-
- private fun bindDetails(addon: Addon) {
- val detailsView = findViewById(R.id.details)
- val detailsText = addon.translateDescription(this)
-
- val parsedText = detailsText.replace("\n", "
")
- val text = HtmlCompat.fromHtml(parsedText, HtmlCompat.FROM_HTML_MODE_COMPACT)
-
- detailsView.text = text
- detailsView.movementMethod = LinkMovementMethod.getInstance()
- }
-
- private fun formatDate(text: String): String {
- val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
- return DateFormat.getDateInstance().format(formatter.parse(text)!!)
- }
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonInternalSettingsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonInternalSettingsActivity.kt
deleted file mode 100644
index 4c26e649..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonInternalSettingsActivity.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.content.Context
-import android.os.Bundle
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.activity.OnBackPressedCallback
-import androidx.appcompat.app.AppCompatActivity
-import eu.weblibre.flutter_mozilla_components.GlobalComponents
-import eu.weblibre.flutter_mozilla_components.R
-import mozilla.components.concept.engine.EngineView
-import mozilla.components.feature.addons.Addon
-import mozilla.components.feature.addons.ui.translateName
-import mozilla.components.support.utils.ext.getParcelableCompat
-
-/**
- * An activity to show the internal settings of an add-on with [EngineView].
- *
- * Used when the addon's manifest specifies `openOptionsPageInTab = false`,
- * rendering the settings page inside an [AddonPopupBaseFragment] with proper
- * prompt and download support.
- */
-class AddonInternalSettingsActivity : AppCompatActivity() {
- private val components by lazy {
- requireNotNull(GlobalComponents.components) { "Components not initialized" }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_on_settings)
-
- val addon = requireNotNull(
- intent.getParcelableExtra("add_on"),
- )
-
- title = addon.translateName(this)
-
- val fragment = AddonInternalSettingsFragment.create(addon)
-
- supportFragmentManager
- .beginTransaction()
- .replace(R.id.addonSettingsContainer, fragment)
- .commit()
-
- onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- if (!fragment.onBackPressed()) {
- finish()
- }
- }
- })
- }
-
- override fun onSupportNavigateUp(): Boolean {
- onBackPressedDispatcher.onBackPressed()
- return true
- }
-
- override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? =
- when (name) {
- EngineView::class.java.name -> components.core.engine.createView(context, attrs).asView()
- else -> super.onCreateView(parent, name, context, attrs)
- }
-
- /**
- * A fragment to show the internal settings of an add-on with [EngineView].
- *
- * Creates a fresh engine session and loads the addon's options page URL into it.
- */
- class AddonInternalSettingsFragment : AddonPopupBaseFragment() {
-
- private val addonSettingsEngineView: EngineView
- get() = requireView().findViewById(R.id.addonSettingsEngineView) as EngineView
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
- initializeSession()
- return inflater.inflate(R.layout.fragment_add_on_settings, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- val optionsPageUrl = arguments?.getParcelableCompat("add_on", Addon::class.java)
- ?.installedState?.optionsPageUrl
-
- if (optionsPageUrl != null) {
- engineSession?.let { session ->
- addonSettingsEngineView.render(session)
- session.loadUrl(optionsPageUrl)
- }
- } else {
- activity?.finish()
- }
- }
-
- companion object {
- fun create(addon: Addon) = AddonInternalSettingsFragment().apply {
- arguments = Bundle().apply {
- putParcelable("add_on", addon)
- }
- }
- }
- }
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPrefs.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPrefs.kt
new file mode 100644
index 00000000..e3cc6af7
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPrefs.kt
@@ -0,0 +1,22 @@
+/*
+ * 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 https://mozilla.org/MPL/2.0/.
+ */
+
+package eu.weblibre.flutter_mozilla_components.addons
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object AddonPrefs {
+ const val PREFS_NAME = "addon_prefs"
+ const val PREF_AUTO_UPDATE_ENABLED = "addon_auto_update_enabled"
+ // Legacy key name — kept to preserve previously pinned local-install preferences.
+ const val PREF_AUTO_UPDATE_DISABLED_IDS = "pinned_local_addon_ids"
+ const val PREF_LOCAL_FILE_ADDON_IDS = "local_file_addon_ids"
+ const val PREF_MANUAL_UPDATE_ATTEMPT_PREFIX = "manual_addon_update_attempt."
+
+ fun get(context: Context): SharedPreferences =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt
deleted file mode 100644
index 5f94e4c2..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.os.Bundle
-import androidx.activity.addCallback
-import androidx.appcompat.app.AppCompatActivity
-import eu.weblibre.flutter_mozilla_components.R
-
-/**
- * An activity to manage add-ons.
- */
-class AddonsActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_on_main)
-
- onBackPressedDispatcher.addCallback(this) {
- finishAndRemoveTask()
- }
-
- if (savedInstanceState == null) {
- supportFragmentManager.beginTransaction().apply {
- replace(R.id.container, AddonsFragment())
- commit()
- }
- }
- }
-
- override fun onSupportNavigateUp(): Boolean {
- onBackPressedDispatcher.onBackPressed()
- return true
- }
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt
deleted file mode 100644
index f34efee4..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt
+++ /dev/null
@@ -1,155 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import eu.weblibre.flutter_mozilla_components.Components
-import eu.weblibre.flutter_mozilla_components.GlobalComponents
-import eu.weblibre.flutter_mozilla_components.ProfileContext
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import mozilla.components.feature.addons.Addon
-import mozilla.components.feature.addons.AddonManagerException
-import mozilla.components.feature.addons.ui.AddonsManagerAdapter
-import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate
-import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
-import eu.weblibre.flutter_mozilla_components.R
-import mozilla.components.feature.addons.R as MozComp
-
-/**
- * Fragment use for managing add-ons.
- */
-class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate {
- private val components by lazy {
- requireNotNull(GlobalComponents.components) { "Components not initialized" }
- }
-
- private val webExtensionPromptFeature = ViewBoundFeatureWrapper()
- private lateinit var recyclerView: RecyclerView
- private val scope = CoroutineScope(Dispatchers.IO)
- private lateinit var addons: List
- private var adapter: AddonsManagerAdapter? = null
-
- private val addonProgressOverlay: View
- get() = requireView().findViewById(R.id.addonProgressOverlay)
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View {
- return inflater.inflate(R.layout.fragment_add_ons, container, false)
- }
-
- override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
- super.onViewCreated(rootView, savedInstanceState)
- bindRecyclerView(rootView)
- webExtensionPromptFeature.set(
- feature = WebExtensionPromptFeature(
- store = components.core.store,
- context = requireContext(),
- fragmentManager = parentFragmentManager,
- ),
- owner = this,
- view = rootView,
- )
- }
-
- override fun onStart() {
- super.onStart()
-
- this@AddonsFragment.view?.let { view ->
- bindRecyclerView(view)
- }
-
- addonProgressOverlay.visibility = View.GONE
- }
-
- private fun bindRecyclerView(rootView: View) {
- val profileContext = ProfileContext(requireContext(), components.profileApplicationContext.relativePath)
-
- recyclerView = rootView.findViewById(R.id.add_ons_list)
- recyclerView.layoutManager = LinearLayoutManager(profileContext)
-
- scope.launch {
- try {
- addons = components.core.addonManager.getAddons()
-
- scope.launch(Dispatchers.Main) {
- adapter = AddonsManagerAdapter(
- this@AddonsFragment,
- addons,
- store = components.core.store,
- )
- recyclerView.adapter = adapter
- }
- } catch (e: AddonManagerException) {
- scope.launch(Dispatchers.Main) {
- Toast.makeText(
- activity,
- MozComp.string.mozac_feature_addons_failed_to_query_extensions,
- Toast.LENGTH_SHORT,
- ).show()
- }
- }
- }
- }
-
- override fun onAddonItemClicked(addon: Addon) {
- if (addon.isInstalled()) {
- val intent = Intent(context, InstalledAddonDetailsActivity::class.java)
- intent.putExtra("add_on", addon)
- startActivity(intent)
- } else {
- val intent = Intent(context, AddonDetailsActivity::class.java)
- intent.putExtra("add_on", addon)
- startActivity(intent)
- }
- }
-
- override fun onInstallAddonButtonClicked(addon: Addon) {
- if (isInstallationInProgress) {
- return
- }
- installAddon(addon)
- }
-
- private val installAddon: ((Addon) -> Unit) = { addon ->
- addonProgressOverlay.visibility = View.VISIBLE
- isInstallationInProgress = true
- components.core.addonManager.installAddon(
- url = addon.downloadUrl,
- onSuccess = {
- runIfFragmentIsAttached {
- isInstallationInProgress = false
- this@AddonsFragment.view?.let { view ->
- bindRecyclerView(view)
- }
- addonProgressOverlay.visibility = View.GONE
- }
- },
- onError = { _ ->
- runIfFragmentIsAttached {
- addonProgressOverlay.visibility = View.GONE
- isInstallationInProgress = false
- }
- },
- )
- }
-
- /**
- * Whether or not an add-on installation is in progress.
- */
- private var isInstallationInProgress = false
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonPopupFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonPopupFragment.kt
new file mode 100644
index 00000000..d65cb0e7
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonPopupFragment.kt
@@ -0,0 +1,106 @@
+/* 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 eu.weblibre.flutter_mozilla_components.addons
+
+import android.os.Bundle
+import android.view.View
+import android.widget.FrameLayout
+import eu.weblibre.flutter_mozilla_components.ProfileContext
+import mozilla.components.browser.state.action.WebExtensionAction
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.lib.state.ext.consumeFrom
+import mozilla.components.support.locale.ActivityContextWrapper
+
+class FlutterAddonPopupFragment : AddonPopupBaseFragment(), EngineSession.Observer {
+ private var addonPopupEngineView: EngineView? = null
+ private var sessionConsumed
+ get() = arguments?.getBoolean("isSessionConsumed", false) ?: false
+ set(value) {
+ arguments?.putBoolean("isSessionConsumed", value)
+ }
+
+ override fun onCreateView(
+ inflater: android.view.LayoutInflater,
+ container: android.view.ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val extensionId = requireNotNull(arguments?.getString(ARG_EXTENSION_ID))
+
+ components.core.store.state.extensions[extensionId]?.popupSession?.let {
+ initializeSession(it)
+ }
+
+ val profileContext = ProfileContext(
+ requireContext(),
+ components.profileApplicationContext.relativePath,
+ )
+ val engineView = components.core.engine.createView(profileContext, null)
+ addonPopupEngineView = engineView
+
+ val originalContext =
+ ActivityContextWrapper.getOriginalContext(requireActivity()) ?: requireActivity()
+ engineView.setActivityContext(originalContext)
+
+ val root = FrameLayout(profileContext)
+ val nativeView = engineView.asView()
+ nativeView.layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ )
+ root.addView(nativeView)
+ return root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val extensionId = requireNotNull(arguments?.getString(ARG_EXTENSION_ID))
+ val currentSession = engineSession
+
+ if (currentSession != null) {
+ addonPopupEngineView?.render(currentSession)
+ consumePopupSession(extensionId)
+ } else {
+ consumeFrom(components.core.store) { state ->
+ state.extensions[extensionId]?.let { extState ->
+ val popupSession = extState.popupSession
+ if (popupSession != null) {
+ initializeSession(popupSession)
+ addonPopupEngineView?.render(popupSession)
+ popupSession.register(this)
+ consumePopupSession(extensionId)
+ engineSession = popupSession
+ } else if (sessionConsumed) {
+ activity?.onBackPressedDispatcher?.onBackPressed()
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ addonPopupEngineView?.setActivityContext(null)
+ addonPopupEngineView = null
+ super.onDestroyView()
+ }
+
+ private fun consumePopupSession(extensionId: String) {
+ components.core.store.dispatch(
+ WebExtensionAction.UpdatePopupSessionAction(extensionId, popupSession = null),
+ )
+ sessionConsumed = true
+ }
+
+ companion object {
+ private const val ARG_EXTENSION_ID = "extension_id"
+
+ fun create(extensionId: String) = FlutterAddonPopupFragment().apply {
+ arguments = Bundle().apply {
+ putString(ARG_EXTENSION_ID, extensionId)
+ }
+ }
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonSettingsFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonSettingsFragment.kt
new file mode 100644
index 00000000..7fd28f53
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/FlutterAddonSettingsFragment.kt
@@ -0,0 +1,75 @@
+/* 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 eu.weblibre.flutter_mozilla_components.addons
+
+import android.os.Bundle
+import android.view.View
+import android.widget.FrameLayout
+import eu.weblibre.flutter_mozilla_components.ProfileContext
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.support.locale.ActivityContextWrapper
+
+class FlutterAddonSettingsFragment : AddonPopupBaseFragment() {
+ private var addonSettingsEngineView: EngineView? = null
+
+ override fun onCreateView(
+ inflater: android.view.LayoutInflater,
+ container: android.view.ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ initializeSession()
+
+ val profileContext = ProfileContext(
+ requireContext(),
+ components.profileApplicationContext.relativePath,
+ )
+ val engineView = components.core.engine.createView(profileContext, null)
+ addonSettingsEngineView = engineView
+
+ val originalContext =
+ ActivityContextWrapper.getOriginalContext(requireActivity()) ?: requireActivity()
+ engineView.setActivityContext(originalContext)
+
+ val root = FrameLayout(profileContext)
+ val nativeView = engineView.asView()
+ nativeView.layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ )
+ root.addView(nativeView)
+ return root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val optionsPageUrl = arguments?.getString(ARG_OPTIONS_PAGE_URL)
+
+ if (optionsPageUrl != null) {
+ engineSession?.let { session ->
+ addonSettingsEngineView?.render(session)
+ session.loadUrl(optionsPageUrl)
+ }
+ } else {
+ activity?.onBackPressedDispatcher?.onBackPressed()
+ }
+ }
+
+ override fun onDestroyView() {
+ addonSettingsEngineView?.setActivityContext(null)
+ addonSettingsEngineView = null
+ super.onDestroyView()
+ }
+
+ companion object {
+ private const val ARG_OPTIONS_PAGE_URL = "options_page_url"
+
+ fun create(optionsPageUrl: String) = FlutterAddonSettingsFragment().apply {
+ arguments = Bundle().apply {
+ putString(ARG_OPTIONS_PAGE_URL, optionsPageUrl)
+ }
+ }
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt
deleted file mode 100644
index d6c4ffd0..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt
+++ /dev/null
@@ -1,221 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.View
-import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
-import androidx.appcompat.widget.SwitchCompat
-import androidx.core.view.isVisible
-import eu.weblibre.flutter_mozilla_components.Components
-import eu.weblibre.flutter_mozilla_components.GlobalComponents
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import mozilla.components.feature.addons.Addon
-import mozilla.components.feature.addons.AddonManagerException
-import mozilla.components.feature.addons.ui.translateName
-import mozilla.components.support.utils.ext.getParcelableExtraCompat
-import eu.weblibre.flutter_mozilla_components.R
-
-/**
- * An activity to show the details of a installed add-on.
- */
-class InstalledAddonDetailsActivity : AppCompatActivity() {
- private val components by lazy {
- requireNotNull(GlobalComponents.components) { "Components not initialized" }
- }
-
- private val scope = CoroutineScope(Dispatchers.IO)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_installed_add_on_details)
- val addon = requireNotNull(
- intent.getParcelableExtraCompat("add_on", Addon::class.java),
- ).also {
- bindUI(it)
- }
-
- bindAddon(addon)
- }
-
- override fun onSupportNavigateUp(): Boolean {
- onBackPressedDispatcher.onBackPressed()
- return true
- }
-
- private fun bindAddon(addon: Addon) {
- scope.launch {
- try {
- val addons = components.core.addonManager.getAddons()
- scope.launch(Dispatchers.Main) {
- addons.find { addon.id == it.id }.let {
- if (it == null) {
- throw AddonManagerException(Exception("Addon ${addon.id} not found"))
- } else {
- bindUI(it)
- }
- }
- }
- } catch (e: AddonManagerException) {
- scope.launch(Dispatchers.Main) {
- Toast.makeText(
- baseContext,
- R.string.mozac_feature_addons_failed_to_query_extensions,
- Toast.LENGTH_SHORT,
- ).show()
- }
- }
- }
- }
-
- private fun bindUI(addon: Addon) {
- title = addon.translateName(this)
-
- bindEnableSwitch(addon)
-
- bindSettings(addon)
-
- bindDetails(addon)
-
- bindPermissions(addon)
-
- bindAllowInPrivateBrowsingSwitch(addon)
-
- bindRemoveButton(addon)
- }
-
- private fun bindEnableSwitch(addon: Addon) {
- val switch = findViewById(R.id.enable_switch)
- switch.setState(addon.isEnabled())
- switch.setOnCheckedChangeListener { _, isChecked ->
- if (isChecked) {
- components.core.addonManager.enableAddon(
- addon,
- onSuccess = {
- switch.setState(true)
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_successfully_enabled, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- },
- onError = {
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_failed_to_enable, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- },
- )
- } else {
- components.core.addonManager.disableAddon(
- addon,
- onSuccess = {
- switch.setState(false)
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_successfully_disabled, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- },
- onError = {
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_failed_to_disable, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- },
- )
- }
- }
- }
-
- private fun bindSettings(addon: Addon) {
- val view = findViewById(R.id.settings)
- view.isVisible = shouldSettingsBeVisible(addon)
- view.isEnabled = shouldSettingsBeVisible(addon)
- view.setOnClickListener {
- val optionsPageUrl = addon.installedState?.optionsPageUrl ?: return@setOnClickListener
- if (addon.installedState?.openOptionsPageInTab == true) {
- // Open settings in a browser tab, reusing an existing tab if already open.
- components.useCases.tabsUseCases.selectOrAddTab(
- url = optionsPageUrl,
- ignoreFragment = true,
- )
- val intent = packageManager.getLaunchIntentForPackage(packageName)
- intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
- startActivity(intent)
- } else {
- // Open settings in an internal view with proper extension API support.
- val intent = Intent(this, AddonInternalSettingsActivity::class.java)
- intent.putExtra("add_on", addon)
- startActivity(intent)
- }
- }
- }
-
- private fun bindDetails(addon: Addon) {
- findViewById(R.id.details).setOnClickListener {
- val intent = Intent(this, AddonDetailsActivity::class.java)
- intent.putExtra("add_on", addon)
- this.startActivity(intent)
- }
- }
-
- private fun bindPermissions(addon: Addon) {
- findViewById(R.id.permissions).setOnClickListener {
- val intent = Intent(this, PermissionsDetailsActivity::class.java)
- intent.putExtra("add_on", addon)
- this.startActivity(intent)
- }
- }
-
- private fun bindAllowInPrivateBrowsingSwitch(addon: Addon) {
- val switch = findViewById(R.id.allow_in_private_browsing_switch)
- switch.isChecked = addon.isAllowedInPrivateBrowsing()
- switch.setOnCheckedChangeListener { _, isChecked ->
- components.core.addonManager.setAddonAllowedInPrivateBrowsing(
- addon,
- isChecked,
- onSuccess = {
- switch.isChecked = isChecked
- },
- )
- }
- }
-
- private fun bindRemoveButton(addon: Addon) {
- findViewById(R.id.remove_add_on).setOnClickListener {
- components.core.addonManager.uninstallAddon(
- addon,
- onSuccess = {
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_successfully_uninstalled, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- finish()
- },
- onError = { _, _ ->
- Toast.makeText(
- this,
- getString(R.string.mozac_feature_addons_failed_to_uninstall, addon.translateName(this)),
- Toast.LENGTH_SHORT,
- ).show()
- },
- )
- }
- }
-
- private fun SwitchCompat.setState(checked: Boolean) {
- isChecked = checked
- }
-
- private fun shouldSettingsBeVisible(addon: Addon) = !addon.installedState?.optionsPageUrl.isNullOrEmpty()
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt
deleted file mode 100644
index f8fe5d8e..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/* 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 eu.weblibre.flutter_mozilla_components.addons
-
-import android.content.Intent
-import android.graphics.Color
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.activity.SystemBarStyle
-import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.net.toUri
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import mozilla.components.feature.addons.Addon
-import mozilla.components.feature.addons.ui.translateName
-import mozilla.components.support.ktx.android.view.setupPersistentInsets
-import mozilla.components.support.utils.ext.getParcelableExtraCompat
-import eu.weblibre.flutter_mozilla_components.R
-
-private const val LEARN_MORE_URL =
- "https://support.mozilla.org/kb/permission-request-messages-firefox-extensions"
-
-/**
- * An activity to show the permissions of an add-on.
- */
-class PermissionsDetailsActivity :
- AppCompatActivity(),
- View.OnClickListener {
- override fun onCreate(savedInstanceState: Bundle?) {
- enableEdgeToEdge(SystemBarStyle.dark(Color.TRANSPARENT))
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_on_permissions)
- window.setupPersistentInsets()
-
- val addon = requireNotNull(
- intent.getParcelableExtraCompat("add_on", Addon::class.java),
- )
-
- title = addon.translateName(this)
-
- bindPermissions(addon)
-
- bindLearnMore()
- }
-
- private fun bindPermissions(addon: Addon) {
- val recyclerView = findViewById(R.id.add_ons_permissions)
- recyclerView.layoutManager = LinearLayoutManager(this)
- val sortedPermissions = addon.translatePermissions(this).sorted()
- recyclerView.adapter = PermissionsAdapter(sortedPermissions)
- }
-
- private fun bindLearnMore() {
- findViewById(R.id.learn_more_label).setOnClickListener(this)
- }
-
- /**
- * An adapter for displaying the permissions of an add-on.
- */
- class PermissionsAdapter(
- private val permissions: List,
- ) : RecyclerView.Adapter() {
- override fun onCreateViewHolder(
- parent: ViewGroup,
- viewType: Int,
- ): PermissionViewHolder {
- val context = parent.context
- val inflater = LayoutInflater.from(context)
- val view = inflater.inflate(R.layout.add_ons_permission_item, parent, false)
- val titleView = view.findViewById(R.id.permission)
- return PermissionViewHolder(
- view,
- titleView,
- )
- }
-
- override fun getItemCount() = permissions.size
-
- override fun onBindViewHolder(
- holder: PermissionViewHolder,
- position: Int,
- ) {
- val permission = permissions[position]
- holder.textView.text = permission
- }
- }
-
- /**
- * A view holder for displaying the permissions of an add-on.
- */
- class PermissionViewHolder(
- val view: View,
- val textView: TextView,
- ) : RecyclerView.ViewHolder(view)
-
- override fun onClick(v: View?) {
- val intent = Intent(Intent.ACTION_VIEW).setData(LEARN_MORE_URL.toUri())
- startActivity(intent)
- }
-}
\ No newline at end of file
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
index 1a096fa6..12dc8475 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
@@ -7,102 +7,767 @@
package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
-import android.content.Intent
import eu.weblibre.flutter_mozilla_components.GlobalComponents
-import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity
-import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
+import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
+import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
+import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
import mozilla.components.concept.engine.webextension.InstallationMethod
+import mozilla.components.concept.engine.webextension.WebExtensionInstallException
+import mozilla.components.feature.addons.Addon
+import mozilla.components.feature.addons.update.AddonUpdater
+import mozilla.components.feature.addons.update.DefaultAddonUpdater
+import mozilla.components.feature.addons.ui.displayName
+import mozilla.components.feature.addons.ui.summary
+import mozilla.components.feature.addons.ui.translateDescription
+import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
+import org.json.JSONObject
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
- private val scope = CoroutineScope(Dispatchers.IO)
-
- override fun startAddonManagerActivity() {
- val intent = Intent(context, AddonsActivity::class.java)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- context.startActivity(intent)
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val updateAttemptStorage by lazy {
+ DefaultAddonUpdater.UpdateAttemptStorage(context.applicationContext)
+ }
+ private val prefs by lazy {
+ AddonPrefs.get(context.applicationContext)
}
- override fun startAddonSettingsActivity(extensionId: String) {
+ companion object {
+ private const val LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE =
+ "No remote update source is available for this locally installed extension."
+ private const val LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE =
+ "Update downloaded and will be applied after restarting the app."
+ private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
+ private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
+ }
+
+ override fun getAddons(allowCache: Boolean, callback: (Result>) -> Unit) {
scope.launch {
- val addon = runCatching {
- components.core.addonManager.getAddons()
- .find { it.id == extensionId }
- }.getOrNull()
-
- if (addon == null) {
- withContext(Dispatchers.Main) {
- startAddonManagerActivity()
- }
- return@launch
- }
-
- val optionsPageUrl = addon.installedState?.optionsPageUrl
- if (optionsPageUrl.isNullOrEmpty()) {
- withContext(Dispatchers.Main) {
- startAddonManagerActivity()
- }
- return@launch
- }
-
- withContext(Dispatchers.Main) {
- if (addon.installedState?.openOptionsPageInTab == true) {
- components.useCases.tabsUseCases.selectOrAddTab(
- url = optionsPageUrl,
- ignoreFragment = true,
- )
- val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
- launchIntent?.addFlags(
- Intent.FLAG_ACTIVITY_NEW_TASK or
- Intent.FLAG_ACTIVITY_CLEAR_TOP or
- Intent.FLAG_ACTIVITY_SINGLE_TOP,
- )
- if (launchIntent != null) {
- context.startActivity(launchIntent)
- } else {
- startAddonManagerActivity()
+ runCatching {
+ components.core.addonManager.getAddons(allowCache = allowCache)
+ .map { addon ->
+ addon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
+ isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
+ )
}
- } else {
- val intent = Intent(context, AddonInternalSettingsActivity::class.java)
- intent.putExtra("add_on", addon)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- context.startActivity(intent)
- }
- }
+ }.fold(
+ onSuccess = { callback(Result.success(it)) },
+ onFailure = { callback(Result.failure(it)) },
+ )
+ }
+ }
+
+ override fun getAddonById(
+ addonId: String,
+ allowCache: Boolean,
+ callback: (Result) -> Unit,
+ ) {
+ scope.launch {
+ runCatching {
+ val installedAddon = components.core.addonManager.getAddonByID(addonId)
+ (installedAddon ?: components.core.addonManager.getAddons(allowCache = allowCache)
+ .find { it.id == addonId })?.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addonId),
+ isLocalFileInstalled = isLocalFileInstalledAddon(addonId),
+ )
+ }.fold(
+ onSuccess = { callback(Result.success(it)) },
+ onFailure = { callback(Result.failure(it)) },
+ )
+ }
+ }
+
+ override fun getAddonStoreInfo(addonId: String, callback: (Result) -> Unit) {
+ scope.launch {
+ runCatching {
+ fetchAddonStoreInfo(addonId)
+ }.fold(
+ onSuccess = { callback(Result.success(it)) },
+ onFailure = { callback(Result.failure(it)) },
+ )
}
}
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
- when(actionType) {
- WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
- WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
+ scope.launch {
+ withContext(Dispatchers.Main.immediate) {
+ when(actionType) {
+ WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
+ WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
+ }
+ }
+ }
+ }
+
+ override fun enableAddon(addonId: String, callback: (Result) -> Unit) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ components.core.addonManager.enableAddon(
+ addon,
+ onSuccess = { updatedAddon ->
+ result(
+ Result.success(
+ updatedAddon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
+ isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
+ ),
+ ),
+ )
+ },
+ onError = { throwable ->
+ result(Result.failure(throwable))
+ },
+ )
+ }
+ }
+
+ override fun disableAddon(addonId: String, callback: (Result) -> Unit) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ components.core.addonManager.disableAddon(
+ addon,
+ onSuccess = { updatedAddon ->
+ result(
+ Result.success(
+ updatedAddon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
+ isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
+ ),
+ ),
+ )
+ },
+ onError = { throwable ->
+ result(Result.failure(throwable))
+ },
+ )
+ }
+ }
+
+ override fun setAddonAllowedInPrivateBrowsing(
+ addonId: String,
+ allowed: Boolean,
+ callback: (Result) -> Unit,
+ ) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ components.core.addonManager.setAddonAllowedInPrivateBrowsing(
+ addon,
+ allowed,
+ onSuccess = { updatedAddon ->
+ result(
+ Result.success(
+ updatedAddon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
+ isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
+ ),
+ ),
+ )
+ },
+ onError = { throwable ->
+ result(Result.failure(throwable))
+ },
+ )
+ }
+ }
+
+ override fun setAddonAutoUpdateEnabledForAddon(
+ addonId: String,
+ enabled: Boolean,
+ callback: (Result) -> Unit,
+ ) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ if (enabled && isLocalFileInstalledAddon(addon.id)) {
+ result(
+ Result.success(
+ addon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = false,
+ isLocalFileInstalled = true,
+ ),
+ ),
+ )
+ return@withInstalledAddon
+ }
+
+ setAddonAutoUpdateEnabledForAddonInternal(addon.id, enabled)
+ result(
+ Result.success(
+ addon.toPigeon(
+ context = context,
+ isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
+ isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
+ ),
+ ),
+ )
+ }
+ }
+
+ override fun uninstallAddon(addonId: String, callback: (Result) -> Unit) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ components.core.addonManager.uninstallAddon(
+ addon,
+ onSuccess = {
+ clearAddonAutoUpdatePreference(addon.id)
+ clearLocalFileInstalledAddon(addon.id)
+ clearManualUpdateAttempt(addon.id)
+ result(Result.success(Unit))
+ },
+ onError = { _, throwable ->
+ result(Result.failure(throwable))
+ },
+ )
+ }
+ }
+
+ override fun triggerAddonUpdate(
+ addonId: String,
+ callback: (Result) -> Unit,
+ ) {
+ if (isLocalFileInstalledAddon(addonId)) {
+ runLocalFileAddonUpdate(addonId, callback)
+ return
+ }
+
+ scope.launch {
+ try {
+ withContext(Dispatchers.Main.immediate) {
+ runManagedAddonUpdate(addonId) { attempt ->
+ callback(Result.success(attempt))
+ }
+ }
+ } catch (throwable: Throwable) {
+ callback(Result.failure(throwable))
+ }
+ }
+ }
+
+ override fun triggerAllAddonUpdates(callback: (Result) -> Unit) {
+ scope.launch {
+ try {
+ val addons = components.core.addonManager.getAddons()
+ .filter { it.isInstalled() && it.isSupported() }
+ .filter { addon -> isAddonAutoUpdateEnabledForAddon(addon.id) }
+ .filterNot { addon -> isLocalFileInstalledAddon(addon.id) }
+
+ withContext(Dispatchers.Main.immediate) {
+ addons.forEach { addon ->
+ scheduleManagedAddonUpdate(addon.id)
+ }
+ }
+
+ callback(Result.success(Unit))
+ } catch (throwable: Throwable) {
+ callback(Result.failure(throwable))
+ }
+ }
+ }
+
+ override fun getLastAddonUpdateAttempt(
+ addonId: String,
+ callback: (Result) -> Unit,
+ ) {
+ scope.launch {
+ runCatching {
+ val updaterAttempt = updateAttemptStorage.findUpdateAttemptBy(addonId)?.toPigeon()
+ val manualAttempt = getManualUpdateAttempt(addonId)
+ listOfNotNull(updaterAttempt, manualAttempt)
+ .maxByOrNull { it.dateMillisecondsSinceEpoch }
+ }.fold(
+ onSuccess = { callback(Result.success(it)) },
+ onFailure = { callback(Result.failure(it)) },
+ )
+ }
+ }
+
+ override fun isAddonAutoUpdateEnabled(callback: (Result) -> Unit) {
+ callback(Result.success(prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)))
+ }
+
+ override fun setAddonAutoUpdateEnabled(enabled: Boolean, callback: (Result) -> Unit) {
+ scope.launch {
+ try {
+ prefs.edit().putBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, enabled).apply()
+
+ val addons = components.core.addonManager.getAddons()
+ .filter { it.isInstalled() && it.isSupported() }
+ withContext(Dispatchers.Main.immediate) {
+ if (enabled) {
+ addons.forEach { addon ->
+ updateAddonAutoUpdateRegistration(addon.id)
+ }
+ } else {
+ addons.forEach { addon ->
+ components.core.addonUpdater.unregisterForFutureUpdates(addon.id)
+ }
+ }
+ }
+
+ callback(Result.success(Unit))
+ } catch (throwable: Throwable) {
+ callback(Result.failure(throwable))
+ }
}
}
override fun installAddon(url: String, callback: (Result) -> Unit) {
- val installMethod = if (url.startsWith("file://")) {
+ val isLocalFileInstall = url.startsWith("file://")
+ val installMethod = if (isLocalFileInstall) {
InstallationMethod.FROM_FILE
} else {
null
}
- components.core.addonManager.installAddon(
- url = url,
- installationMethod = installMethod,
- onSuccess = { _ ->
- callback(Result.success(Unit))
- },
- onError = { e ->
- callback(Result.failure(e))
+ scope.launch {
+ try {
+ withContext(Dispatchers.Main.immediate) {
+ performAddonInstall(url, isLocalFileInstall, callback)
+ }
+ } catch (throwable: Throwable) {
+ callback(Result.failure(throwable))
}
+ }
+ }
+
+ private fun withInstalledAddon(
+ addonId: String,
+ callback: (Result) -> Unit,
+ block: suspend (Addon, (Result) -> Unit) -> Unit,
+ ) {
+ scope.launch {
+ val addon = runCatching {
+ components.core.addonManager.getAddonByID(addonId)
+ }.getOrNull()
+
+ if (addon == null) {
+ callback(Result.failure(IllegalStateException("Addon $addonId not found")))
+ return@launch
+ }
+
+ withContext(Dispatchers.Main.immediate) {
+ block(addon, callback)
+ }
+ }
+ }
+
+ private fun isAutoUpdateEnabled(): Boolean {
+ return prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
+ }
+
+ private fun isAddonAutoUpdateEnabledForAddon(addonId: String): Boolean {
+ return !getAddonAutoUpdateDisabledIds().contains(addonId)
+ }
+
+ private fun isAutoUpdateEffectivelyEnabledForAddon(addonId: String): Boolean {
+ return isAddonAutoUpdateEnabledForAddon(addonId) && !isLocalFileInstalledAddon(addonId)
+ }
+
+ private fun isLocalFileInstalledAddon(addonId: String): Boolean {
+ return getLocalFileAddonIds().contains(addonId)
+ }
+
+ private fun getAddonAutoUpdateDisabledIds(): Set {
+ return prefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet()) ?: emptySet()
+ }
+
+ private fun getLocalFileAddonIds(): Set {
+ return prefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet()) ?: emptySet()
+ }
+
+ private suspend fun fetchAddonStoreInfo(addonId: String): AddonStoreInfo? {
+ val response = components.core.client.fetch(
+ Request(
+ url = addonStoreInfoUrl(addonId),
+ method = Request.Method.GET,
+ headers = MutableHeaders("Accept" to "application/json"),
+ ),
+ )
+ if (response.status !in 200..299) {
+ return null
+ }
+
+ val responseBody = response.body.useStream { stream ->
+ String(stream.readAllBytes(), Charsets.UTF_8)
+ }
+ val json = JSONObject(responseBody)
+ val currentVersion = json.optJSONObject("current_version") ?: return null
+ val latestVersion = currentVersion.optString("version")
+ val latestXpiUrl = currentVersion.optJSONObject("file")?.optString("url").orEmpty()
+ if (latestVersion.isBlank() || latestXpiUrl.isBlank()) {
+ return null
+ }
+
+ return AddonStoreInfo(
+ latestVersion = latestVersion,
+ latestXpiUrl = latestXpiUrl,
)
}
+
+ private fun addonStoreInfoUrl(addonId: String): String {
+ val baseUrl = components.addonCollection?.serverURL?.trimEnd('/') ?: DEFAULT_AMO_SERVER_URL
+ return "$baseUrl/api/v5/addons/addon/$addonId/"
+ }
+
+ private fun saveManualUpdateAttempt(
+ addonId: String,
+ status: AddonUpdateStatus,
+ message: String? = null,
+ ) {
+ prefs.edit()
+ .putLong(manualAttemptTimestampKey(addonId), System.currentTimeMillis())
+ .putString(manualAttemptStatusKey(addonId), status.name)
+ .putString(manualAttemptMessageKey(addonId), message)
+ .apply()
+ }
+
+ private fun getManualUpdateAttempt(addonId: String): AddonUpdateAttemptInfo? {
+ val timestamp = prefs.getLong(manualAttemptTimestampKey(addonId), -1L)
+ if (timestamp < 0) {
+ return null
+ }
+
+ val status = prefs.getString(manualAttemptStatusKey(addonId), null)
+ ?.let { runCatching { AddonUpdateStatus.valueOf(it) }.getOrNull() }
+
+ return AddonUpdateAttemptInfo(
+ addonId = addonId,
+ dateMillisecondsSinceEpoch = timestamp,
+ status = status,
+ message = prefs.getString(manualAttemptMessageKey(addonId), null),
+ )
+ }
+
+ private fun clearManualUpdateAttempt(addonId: String) {
+ prefs.edit()
+ .remove(manualAttemptTimestampKey(addonId))
+ .remove(manualAttemptStatusKey(addonId))
+ .remove(manualAttemptMessageKey(addonId))
+ .apply()
+ }
+
+ private fun manualAttemptTimestampKey(addonId: String): String {
+ return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.timestamp"
+ }
+
+ private fun manualAttemptStatusKey(addonId: String): String {
+ return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.status"
+ }
+
+ private fun manualAttemptMessageKey(addonId: String): String {
+ return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.message"
+ }
+
+ private fun performAddonInstall(
+ url: String,
+ isLocalFileInstall: Boolean,
+ callback: (Result) -> Unit,
+ ) {
+ val installationMethod = if (isLocalFileInstall) InstallationMethod.FROM_FILE else null
+
+ components.core.addonManager.installAddon(
+ url = url,
+ installationMethod = installationMethod,
+ onSuccess = { installedAddon ->
+ applyInstallUpdatePolicy(installedAddon.id, isLocalFileInstall)
+ callback(Result.success(Unit))
+ },
+ onError = { error ->
+ callback(Result.failure(error))
+ },
+ )
+ }
+
+ private fun applyInstallUpdatePolicy(addonId: String, isLocalFileInstall: Boolean) {
+ if (isLocalFileInstall) {
+ markAddonAsLocalFileInstalled(addonId)
+ setAddonAutoUpdateEnabledForAddonInternal(addonId, false)
+ } else {
+ clearLocalFileInstalledAddon(addonId)
+ updateAddonAutoUpdateRegistration(addonId)
+ }
+ }
+
+ private fun runLocalFileAddonUpdate(
+ addonId: String,
+ callback: (Result) -> Unit,
+ ) {
+ withInstalledAddon(addonId, callback) { addon, result ->
+ val storeInfo = fetchAddonStoreInfo(addonId)
+ if (storeInfo == null) {
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.ERROR,
+ message = LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE,
+ )
+ result(Result.failure(IllegalStateException(LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE)))
+ return@withInstalledAddon
+ }
+
+ if (addon.installedState?.version == storeInfo.latestVersion) {
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.NO_UPDATE_AVAILABLE,
+ )
+ result(Result.success(getManualUpdateAttempt(addonId)))
+ return@withInstalledAddon
+ }
+
+ components.core.addonManager.uninstallAddon(
+ addon,
+ onSuccess = {
+ performAddonInstall(
+ url = storeInfo.latestXpiUrl,
+ isLocalFileInstall = false,
+ callback = { installResult ->
+ installResult.fold(
+ onSuccess = {
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
+ )
+ result(Result.success(getManualUpdateAttempt(addonId)))
+ },
+ onFailure = { throwable ->
+ if (isPostponedInstallException(throwable)) {
+ clearLocalFileInstalledAddon(addonId)
+ updateAddonAutoUpdateRegistration(addonId)
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
+ message = LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE,
+ )
+ result(Result.success(getManualUpdateAttempt(addonId)))
+ return@fold
+ }
+
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.ERROR,
+ message = throwable.message,
+ )
+ result(Result.failure(throwable))
+ },
+ )
+ },
+ )
+ },
+ onError = { _, throwable ->
+ saveManualUpdateAttempt(
+ addonId = addonId,
+ status = AddonUpdateStatus.ERROR,
+ message = throwable.message,
+ )
+ result(Result.failure(throwable))
+ },
+ )
+ }
+ }
+
+ private fun setAddonAutoUpdateEnabledForAddonInternal(addonId: String, enabled: Boolean) {
+ val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
+ val changed = if (enabled) {
+ disabledIds.remove(addonId)
+ } else {
+ disabledIds.add(addonId)
+ }
+
+ if (changed) {
+ prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
+ }
+
+ updateAddonAutoUpdateRegistration(addonId)
+ }
+
+ private fun clearAddonAutoUpdatePreference(addonId: String) {
+ val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
+ if (disabledIds.remove(addonId)) {
+ prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
+ }
+ }
+
+ private fun markAddonAsLocalFileInstalled(addonId: String) {
+ val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
+ if (localFileAddonIds.add(addonId)) {
+ prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
+ }
+ }
+
+ private fun clearLocalFileInstalledAddon(addonId: String) {
+ val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
+ if (localFileAddonIds.remove(addonId)) {
+ prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
+ }
+ }
+
+ private fun isPostponedInstallException(throwable: Throwable): Boolean {
+ val installThrowable = when (throwable) {
+ is WebExtensionInstallException -> throwable.cause
+ else -> throwable.cause
+ }
+
+ return installThrowable is org.mozilla.geckoview.WebExtension.InstallException &&
+ installThrowable.code == ERROR_POSTPONED
+ }
+
+ private fun updateAddonAutoUpdateRegistration(addonId: String) {
+ if (
+ isAutoUpdateEnabled() &&
+ isAddonAutoUpdateEnabledForAddon(addonId) &&
+ !isLocalFileInstalledAddon(addonId)
+ ) {
+ components.core.addonUpdater.registerForFutureUpdates(addonId)
+ } else {
+ components.core.addonUpdater.unregisterForFutureUpdates(addonId)
+ }
+ }
+
+ private fun scheduleManagedAddonUpdate(addonId: String) {
+ val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
+ isAddonAutoUpdateEnabledForAddon(addonId)
+ if (shouldRestorePeriodicRegistration) {
+ components.core.addonUpdater.unregisterForFutureUpdates(addonId)
+ }
+
+ components.core.addonUpdater.update(addonId)
+
+ if (shouldRestorePeriodicRegistration) {
+ scope.launch {
+ delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
+ withContext(Dispatchers.Main.immediate) {
+ updateAddonAutoUpdateRegistration(addonId)
+ }
+ }
+ }
+ }
+
+ private fun runManagedAddonUpdate(
+ addonId: String,
+ onComplete: (AddonUpdateAttemptInfo?) -> Unit,
+ ) {
+ val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
+ isAddonAutoUpdateEnabledForAddon(addonId)
+ if (shouldRestorePeriodicRegistration) {
+ components.core.addonUpdater.unregisterForFutureUpdates(addonId)
+ }
+
+ components.core.addonManager.updateAddon(addonId) { status ->
+ val pigeonStatus = status.toPigeon()
+ val message = (status as? AddonUpdater.Status.Error)?.message
+ saveManualUpdateAttempt(addonId, pigeonStatus, message)
+ onComplete(getManualUpdateAttempt(addonId))
+
+ if (shouldRestorePeriodicRegistration) {
+ scope.launch {
+ delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
+ withContext(Dispatchers.Main.immediate) {
+ updateAddonAutoUpdateRegistration(addonId)
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Addon.toPigeon(
+ context: Context,
+ isAutoUpdateEnabled: Boolean,
+ isLocalFileInstalled: Boolean,
+): AddonInfo {
+ val installedState = installedState
+ val localizedName = displayName(context)
+ val localizedSummary = summary(context)
+ val localizedDescription = if (translatableDescription.isNotEmpty()) {
+ translateDescription(context)
+ } else {
+ ""
+ }
+
+ return AddonInfo(
+ id = id,
+ displayName = localizedName,
+ summary = localizedSummary,
+ description = localizedDescription,
+ downloadUrl = downloadUrl,
+ version = version,
+ installedVersion = installedState?.version,
+ translatedPermissions = translatePermissions(context),
+ translatedRequiredDataCollectionPermissions =
+ translateRequiredDataCollectionPermissions(context),
+ authorName = author?.name,
+ authorUrl = author?.url,
+ homepageUrl = homepageUrl,
+ detailUrl = detailUrl,
+ ratingUrl = ratingUrl,
+ ratingAverage = rating?.average?.toDouble(),
+ ratingReviews = rating?.reviews?.toLong(),
+ createdAt = createdAt,
+ updatedAt = updatedAt,
+ icon = provideIcon()?.toWebPBytes(),
+ isInstalled = isInstalled(),
+ isEnabled = isEnabled(),
+ isSupported = isSupported(),
+ isAllowedInPrivateBrowsing = isAllowedInPrivateBrowsing(),
+ isAutoUpdateEnabled = isAutoUpdateEnabled,
+ isLocalFileInstalled = isLocalFileInstalled,
+ optionsPageUrl = installedState?.optionsPageUrl,
+ openOptionsPageInTab = installedState?.openOptionsPageInTab ?: false,
+ disabledReason = installedState?.disabledReason?.toPigeon(),
+ incognito = incognito.toPigeon(),
+ )
+}
+
+private fun Addon.DisabledReason.toPigeon(): AddonDisabledReason {
+ return when (this) {
+ Addon.DisabledReason.UNSUPPORTED -> AddonDisabledReason.UNSUPPORTED
+ Addon.DisabledReason.BLOCKLISTED -> AddonDisabledReason.BLOCKLISTED
+ Addon.DisabledReason.USER_REQUESTED -> AddonDisabledReason.USER_REQUESTED
+ Addon.DisabledReason.NOT_CORRECTLY_SIGNED -> AddonDisabledReason.NOT_CORRECTLY_SIGNED
+ Addon.DisabledReason.INCOMPATIBLE -> AddonDisabledReason.INCOMPATIBLE
+ Addon.DisabledReason.SOFT_BLOCKED -> AddonDisabledReason.SOFT_BLOCKED
+ }
+}
+
+private fun Addon.Incognito.toPigeon(): AddonIncognito {
+ return when (this) {
+ Addon.Incognito.SPANNING -> AddonIncognito.SPANNING
+ Addon.Incognito.SPLIT -> AddonIncognito.SPLIT
+ Addon.Incognito.NOT_ALLOWED -> AddonIncognito.NOT_ALLOWED
+ }
+}
+
+private fun AddonUpdater.UpdateAttempt.toPigeon(): AddonUpdateAttemptInfo {
+ return AddonUpdateAttemptInfo(
+ addonId = addonId,
+ dateMillisecondsSinceEpoch = date.time,
+ status = status?.toPigeon(),
+ message = (status as? AddonUpdater.Status.Error)?.message,
+ )
+}
+
+private fun AddonUpdater.Status.toPigeon(): AddonUpdateStatus {
+ return when (this) {
+ AddonUpdater.Status.NotInstalled -> AddonUpdateStatus.NOT_INSTALLED
+ AddonUpdater.Status.SuccessfullyUpdated -> AddonUpdateStatus.SUCCESSFULLY_UPDATED
+ AddonUpdater.Status.NoUpdateAvailable -> AddonUpdateStatus.NO_UPDATE_AVAILABLE
+ is AddonUpdater.Status.Error -> AddonUpdateStatus.ERROR
+ }
}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
index 2378d50e..bc27c720 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
@@ -10,6 +10,8 @@ import android.app.Activity
import android.content.Intent
import android.view.View
import androidx.fragment.app.FragmentActivity
+import eu.weblibre.flutter_mozilla_components.AddonPopupViewFactory
+import eu.weblibre.flutter_mozilla_components.AddonSettingsViewFactory
import eu.weblibre.flutter_mozilla_components.BrowserFragment
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
import eu.weblibre.flutter_mozilla_components.EngineProvider
@@ -145,6 +147,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
_flutterEvents
)
)
+ _flutterPluginBinding.platformViewRegistry.registerViewFactory(
+ "eu.weblibre/addon_settings",
+ AddonSettingsViewFactory(activityProvider = { this.activity }),
+ )
+ _flutterPluginBinding.platformViewRegistry.registerViewFactory(
+ "eu.weblibre/addon_popup",
+ AddonPopupViewFactory(activityProvider = { this.activity }),
+ )
isPlatformViewRegistered = true
isGeckoInitialized = false
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
index 4bfe1fbb..cc8b250b 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
@@ -350,6 +350,46 @@ enum class WebExtensionActionType(val raw: Int) {
}
}
+enum class AddonDisabledReason(val raw: Int) {
+ UNSUPPORTED(0),
+ BLOCKLISTED(1),
+ USER_REQUESTED(2),
+ NOT_CORRECTLY_SIGNED(3),
+ INCOMPATIBLE(4),
+ SOFT_BLOCKED(5);
+
+ companion object {
+ fun ofRaw(raw: Int): AddonDisabledReason? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+enum class AddonIncognito(val raw: Int) {
+ SPANNING(0),
+ SPLIT(1),
+ NOT_ALLOWED(2);
+
+ companion object {
+ fun ofRaw(raw: Int): AddonIncognito? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+enum class AddonUpdateStatus(val raw: Int) {
+ NOT_INSTALLED(0),
+ SUCCESSFULLY_UPDATED(1),
+ NO_UPDATE_AVAILABLE(2),
+ ERROR(3);
+
+ companion object {
+ fun ofRaw(raw: Int): AddonUpdateStatus? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
enum class GeckoSuggestionType(val raw: Int) {
SESSION(0),
CLIPBOARD(1),
@@ -2480,6 +2520,236 @@ data class WebExtensionData (
}
}
+/** Generated class from Pigeon that represents data sent in messages. */
+data class AddonInfo (
+ val id: String,
+ val displayName: String,
+ val summary: String? = null,
+ val description: String,
+ val downloadUrl: String,
+ val version: String,
+ val installedVersion: String? = null,
+ val translatedPermissions: List,
+ val translatedRequiredDataCollectionPermissions: List,
+ val authorName: String? = null,
+ val authorUrl: String? = null,
+ val homepageUrl: String,
+ val detailUrl: String,
+ val ratingUrl: String,
+ val ratingAverage: Double? = null,
+ val ratingReviews: Long? = null,
+ val createdAt: String,
+ val updatedAt: String,
+ val icon: ByteArray? = null,
+ val isInstalled: Boolean,
+ val isEnabled: Boolean,
+ val isSupported: Boolean,
+ val isAllowedInPrivateBrowsing: Boolean,
+ val isAutoUpdateEnabled: Boolean,
+ val isLocalFileInstalled: Boolean,
+ val optionsPageUrl: String? = null,
+ val openOptionsPageInTab: Boolean,
+ val disabledReason: AddonDisabledReason? = null,
+ val incognito: AddonIncognito
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AddonInfo {
+ val id = pigeonVar_list[0] as String
+ val displayName = pigeonVar_list[1] as String
+ val summary = pigeonVar_list[2] as String?
+ val description = pigeonVar_list[3] as String
+ val downloadUrl = pigeonVar_list[4] as String
+ val version = pigeonVar_list[5] as String
+ val installedVersion = pigeonVar_list[6] as String?
+ val translatedPermissions = pigeonVar_list[7] as List
+ val translatedRequiredDataCollectionPermissions = pigeonVar_list[8] as List
+ val authorName = pigeonVar_list[9] as String?
+ val authorUrl = pigeonVar_list[10] as String?
+ val homepageUrl = pigeonVar_list[11] as String
+ val detailUrl = pigeonVar_list[12] as String
+ val ratingUrl = pigeonVar_list[13] as String
+ val ratingAverage = pigeonVar_list[14] as Double?
+ val ratingReviews = pigeonVar_list[15] as Long?
+ val createdAt = pigeonVar_list[16] as String
+ val updatedAt = pigeonVar_list[17] as String
+ val icon = pigeonVar_list[18] as ByteArray?
+ val isInstalled = pigeonVar_list[19] as Boolean
+ val isEnabled = pigeonVar_list[20] as Boolean
+ val isSupported = pigeonVar_list[21] as Boolean
+ val isAllowedInPrivateBrowsing = pigeonVar_list[22] as Boolean
+ val isAutoUpdateEnabled = pigeonVar_list[23] as Boolean
+ val isLocalFileInstalled = pigeonVar_list[24] as Boolean
+ val optionsPageUrl = pigeonVar_list[25] as String?
+ val openOptionsPageInTab = pigeonVar_list[26] as Boolean
+ val disabledReason = pigeonVar_list[27] as AddonDisabledReason?
+ val incognito = pigeonVar_list[28] as AddonIncognito
+ return AddonInfo(id, displayName, summary, description, downloadUrl, version, installedVersion, translatedPermissions, translatedRequiredDataCollectionPermissions, authorName, authorUrl, homepageUrl, detailUrl, ratingUrl, ratingAverage, ratingReviews, createdAt, updatedAt, icon, isInstalled, isEnabled, isSupported, isAllowedInPrivateBrowsing, isAutoUpdateEnabled, isLocalFileInstalled, optionsPageUrl, openOptionsPageInTab, disabledReason, incognito)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ id,
+ displayName,
+ summary,
+ description,
+ downloadUrl,
+ version,
+ installedVersion,
+ translatedPermissions,
+ translatedRequiredDataCollectionPermissions,
+ authorName,
+ authorUrl,
+ homepageUrl,
+ detailUrl,
+ ratingUrl,
+ ratingAverage,
+ ratingReviews,
+ createdAt,
+ updatedAt,
+ icon,
+ isInstalled,
+ isEnabled,
+ isSupported,
+ isAllowedInPrivateBrowsing,
+ isAutoUpdateEnabled,
+ isLocalFileInstalled,
+ optionsPageUrl,
+ openOptionsPageInTab,
+ disabledReason,
+ incognito,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AddonInfo
+ return GeckoPigeonUtils.deepEquals(this.id, other.id) && GeckoPigeonUtils.deepEquals(this.displayName, other.displayName) && GeckoPigeonUtils.deepEquals(this.summary, other.summary) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.downloadUrl, other.downloadUrl) && GeckoPigeonUtils.deepEquals(this.version, other.version) && GeckoPigeonUtils.deepEquals(this.installedVersion, other.installedVersion) && GeckoPigeonUtils.deepEquals(this.translatedPermissions, other.translatedPermissions) && GeckoPigeonUtils.deepEquals(this.translatedRequiredDataCollectionPermissions, other.translatedRequiredDataCollectionPermissions) && GeckoPigeonUtils.deepEquals(this.authorName, other.authorName) && GeckoPigeonUtils.deepEquals(this.authorUrl, other.authorUrl) && GeckoPigeonUtils.deepEquals(this.homepageUrl, other.homepageUrl) && GeckoPigeonUtils.deepEquals(this.detailUrl, other.detailUrl) && GeckoPigeonUtils.deepEquals(this.ratingUrl, other.ratingUrl) && GeckoPigeonUtils.deepEquals(this.ratingAverage, other.ratingAverage) && GeckoPigeonUtils.deepEquals(this.ratingReviews, other.ratingReviews) && GeckoPigeonUtils.deepEquals(this.createdAt, other.createdAt) && GeckoPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && GeckoPigeonUtils.deepEquals(this.icon, other.icon) && GeckoPigeonUtils.deepEquals(this.isInstalled, other.isInstalled) && GeckoPigeonUtils.deepEquals(this.isEnabled, other.isEnabled) && GeckoPigeonUtils.deepEquals(this.isSupported, other.isSupported) && GeckoPigeonUtils.deepEquals(this.isAllowedInPrivateBrowsing, other.isAllowedInPrivateBrowsing) && GeckoPigeonUtils.deepEquals(this.isAutoUpdateEnabled, other.isAutoUpdateEnabled) && GeckoPigeonUtils.deepEquals(this.isLocalFileInstalled, other.isLocalFileInstalled) && GeckoPigeonUtils.deepEquals(this.optionsPageUrl, other.optionsPageUrl) && GeckoPigeonUtils.deepEquals(this.openOptionsPageInTab, other.openOptionsPageInTab) && GeckoPigeonUtils.deepEquals(this.disabledReason, other.disabledReason) && GeckoPigeonUtils.deepEquals(this.incognito, other.incognito)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.id)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.displayName)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.summary)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.description)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.downloadUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.version)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.installedVersion)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.translatedPermissions)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.translatedRequiredDataCollectionPermissions)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.authorName)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.authorUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.homepageUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.detailUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.ratingUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.ratingAverage)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.ratingReviews)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.createdAt)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.updatedAt)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.icon)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isInstalled)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isEnabled)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isSupported)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isAllowedInPrivateBrowsing)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isAutoUpdateEnabled)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isLocalFileInstalled)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.optionsPageUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.openOptionsPageInTab)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.disabledReason)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.incognito)
+ return result
+ }
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class AddonStoreInfo (
+ val latestVersion: String,
+ val latestXpiUrl: String
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AddonStoreInfo {
+ val latestVersion = pigeonVar_list[0] as String
+ val latestXpiUrl = pigeonVar_list[1] as String
+ return AddonStoreInfo(latestVersion, latestXpiUrl)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ latestVersion,
+ latestXpiUrl,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AddonStoreInfo
+ return GeckoPigeonUtils.deepEquals(this.latestVersion, other.latestVersion) && GeckoPigeonUtils.deepEquals(this.latestXpiUrl, other.latestXpiUrl)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.latestVersion)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.latestXpiUrl)
+ return result
+ }
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class AddonUpdateAttemptInfo (
+ val addonId: String,
+ val dateMillisecondsSinceEpoch: Long,
+ val status: AddonUpdateStatus? = null,
+ val message: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AddonUpdateAttemptInfo {
+ val addonId = pigeonVar_list[0] as String
+ val dateMillisecondsSinceEpoch = pigeonVar_list[1] as Long
+ val status = pigeonVar_list[2] as AddonUpdateStatus?
+ val message = pigeonVar_list[3] as String?
+ return AddonUpdateAttemptInfo(addonId, dateMillisecondsSinceEpoch, status, message)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ addonId,
+ dateMillisecondsSinceEpoch,
+ status,
+ message,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AddonUpdateAttemptInfo
+ return GeckoPigeonUtils.deepEquals(this.addonId, other.addonId) && GeckoPigeonUtils.deepEquals(this.dateMillisecondsSinceEpoch, other.dateMillisecondsSinceEpoch) && GeckoPigeonUtils.deepEquals(this.status, other.status) && GeckoPigeonUtils.deepEquals(this.message, other.message)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.addonId)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.dateMillisecondsSinceEpoch)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.status)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.message)
+ return result
+ }
+}
+
/** Generated class from Pigeon that represents data sent in messages. */
data class GeckoSuggestion (
val id: String,
@@ -4729,485 +4999,515 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
}
138.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- GeckoSuggestionType.ofRaw(it.toInt())
+ AddonDisabledReason.ofRaw(it.toInt())
}
}
139.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- TrackingProtectionPolicy.ofRaw(it.toInt())
+ AddonIncognito.ofRaw(it.toInt())
}
}
140.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- HttpsOnlyMode.ofRaw(it.toInt())
+ AddonUpdateStatus.ofRaw(it.toInt())
}
}
141.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- QueryParameterStripping.ofRaw(it.toInt())
+ GeckoSuggestionType.ofRaw(it.toInt())
}
}
142.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- BounceTrackingProtectionMode.ofRaw(it.toInt())
+ TrackingProtectionPolicy.ofRaw(it.toInt())
}
}
143.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- ColorScheme.ofRaw(it.toInt())
+ HttpsOnlyMode.ofRaw(it.toInt())
}
}
144.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- CookieBannerHandlingMode.ofRaw(it.toInt())
+ QueryParameterStripping.ofRaw(it.toInt())
}
}
145.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- AppLinksMode.ofRaw(it.toInt())
+ BounceTrackingProtectionMode.ofRaw(it.toInt())
}
}
146.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- WebContentIsolationStrategy.ofRaw(it.toInt())
+ ColorScheme.ofRaw(it.toInt())
}
}
147.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- CustomCookiePolicy.ofRaw(it.toInt())
+ CookieBannerHandlingMode.ofRaw(it.toInt())
}
}
148.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- TrackingScope.ofRaw(it.toInt())
+ AppLinksMode.ofRaw(it.toInt())
}
}
149.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- DohSettingsMode.ofRaw(it.toInt())
+ WebContentIsolationStrategy.ofRaw(it.toInt())
}
}
150.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- DownloadStatus.ofRaw(it.toInt())
+ CustomCookiePolicy.ofRaw(it.toInt())
}
}
151.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- LogLevel.ofRaw(it.toInt())
+ TrackingScope.ofRaw(it.toInt())
}
}
152.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- SyncEngineValue.ofRaw(it.toInt())
+ DohSettingsMode.ofRaw(it.toInt())
}
}
153.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- MlProgressType.ofRaw(it.toInt())
+ DownloadStatus.ofRaw(it.toInt())
}
}
154.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- MlProgressStatus.ofRaw(it.toInt())
+ LogLevel.ofRaw(it.toInt())
}
}
155.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- ClearDataType.ofRaw(it.toInt())
+ SyncEngineValue.ofRaw(it.toInt())
}
}
156.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- GeckoFetchMethod.ofRaw(it.toInt())
+ MlProgressType.ofRaw(it.toInt())
}
}
157.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- GeckoFetchRedircet.ofRaw(it.toInt())
+ MlProgressStatus.ofRaw(it.toInt())
}
}
158.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- GeckoFetchCookiePolicy.ofRaw(it.toInt())
+ ClearDataType.ofRaw(it.toInt())
}
}
159.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- BookmarkNodeType.ofRaw(it.toInt())
+ GeckoFetchMethod.ofRaw(it.toInt())
}
}
160.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- SitePermissionStatus.ofRaw(it.toInt())
+ GeckoFetchRedircet.ofRaw(it.toInt())
}
}
161.toByte() -> {
return (readValue(buffer) as Long?)?.let {
- AutoplayStatus.ofRaw(it.toInt())
+ GeckoFetchCookiePolicy.ofRaw(it.toInt())
}
}
162.toByte() -> {
- return (readValue(buffer) as? List)?.let {
- TranslationOptions.fromList(it)
+ return (readValue(buffer) as Long?)?.let {
+ BookmarkNodeType.ofRaw(it.toInt())
}
}
163.toByte() -> {
- return (readValue(buffer) as? List)?.let {
- TranslationLanguage.fromList(it)
+ return (readValue(buffer) as Long?)?.let {
+ SitePermissionStatus.ofRaw(it.toInt())
}
}
164.toByte() -> {
- return (readValue(buffer) as? List)?.let {
- TranslationDetectedLanguages.fromList(it)
+ return (readValue(buffer) as Long?)?.let {
+ AutoplayStatus.ofRaw(it.toInt())
}
}
165.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TranslationPair.fromList(it)
+ TranslationOptions.fromList(it)
}
}
166.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TranslationEngineStateData.fromList(it)
+ TranslationLanguage.fromList(it)
}
}
167.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TabTranslationStateData.fromList(it)
+ TranslationDetectedLanguages.fromList(it)
}
}
168.toByte() -> {
return (readValue(buffer) as? List)?.let {
- ReaderState.fromList(it)
+ TranslationPair.fromList(it)
}
}
169.toByte() -> {
return (readValue(buffer) as? List)?.let {
- AddTabParams.fromList(it)
+ TranslationEngineStateData.fromList(it)
}
}
170.toByte() -> {
return (readValue(buffer) as? List)?.let {
- LastMediaAccessState.fromList(it)
+ TabTranslationStateData.fromList(it)
}
}
171.toByte() -> {
return (readValue(buffer) as? List)?.let {
- HistoryMetadataKey.fromList(it)
+ ReaderState.fromList(it)
}
}
172.toByte() -> {
return (readValue(buffer) as? List)?.let {
- PackageCategoryValue.fromList(it)
+ AddTabParams.fromList(it)
}
}
173.toByte() -> {
return (readValue(buffer) as? List)?.let {
- ExternalPackage.fromList(it)
+ LastMediaAccessState.fromList(it)
}
}
174.toByte() -> {
return (readValue(buffer) as? List)?.let {
- LoadUrlFlagsValue.fromList(it)
+ HistoryMetadataKey.fromList(it)
}
}
175.toByte() -> {
return (readValue(buffer) as? List)?.let {
- SourceValue.fromList(it)
+ PackageCategoryValue.fromList(it)
}
}
176.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TabState.fromList(it)
+ ExternalPackage.fromList(it)
}
}
177.toByte() -> {
return (readValue(buffer) as? List)?.let {
- RecoverableTab.fromList(it)
+ LoadUrlFlagsValue.fromList(it)
}
}
178.toByte() -> {
return (readValue(buffer) as? List)?.let {
- IconRequest.fromList(it)
+ SourceValue.fromList(it)
}
}
179.toByte() -> {
return (readValue(buffer) as? List)?.let {
- ResourceSize.fromList(it)
+ TabState.fromList(it)
}
}
180.toByte() -> {
return (readValue(buffer) as? List)?.let {
- Resource.fromList(it)
+ RecoverableTab.fromList(it)
}
}
181.toByte() -> {
return (readValue(buffer) as? List)?.let {
- IconResult.fromList(it)
+ IconRequest.fromList(it)
}
}
182.toByte() -> {
return (readValue(buffer) as? List)?.let {
- CookiePartitionKey.fromList(it)
+ ResourceSize.fromList(it)
}
}
183.toByte() -> {
return (readValue(buffer) as? List)?.let {
- Cookie.fromList(it)
+ Resource.fromList(it)
}
}
184.toByte() -> {
return (readValue(buffer) as? List)?.let {
- VisitInfo.fromList(it)
+ IconResult.fromList(it)
}
}
185.toByte() -> {
return (readValue(buffer) as? List)?.let {
- HistoryHighlightWeights.fromList(it)
+ CookiePartitionKey.fromList(it)
}
}
186.toByte() -> {
return (readValue(buffer) as? List)?.let {
- HistoryHighlight.fromList(it)
+ Cookie.fromList(it)
}
}
187.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TopFrecentSiteInfo.fromList(it)
+ VisitInfo.fromList(it)
}
}
188.toByte() -> {
return (readValue(buffer) as? List)?.let {
- HistoryItem.fromList(it)
+ HistoryHighlightWeights.fromList(it)
}
}
189.toByte() -> {
return (readValue(buffer) as? List)?.let {
- HistoryState.fromList(it)
+ HistoryHighlight.fromList(it)
}
}
190.toByte() -> {
return (readValue(buffer) as? List)?.let {
- ReaderableState.fromList(it)
+ TopFrecentSiteInfo.fromList(it)
}
}
191.toByte() -> {
return (readValue(buffer) as? List)?.let {
- SecurityInfoState.fromList(it)
+ HistoryItem.fromList(it)
}
}
192.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TabContentState.fromList(it)
+ HistoryState.fromList(it)
}
}
193.toByte() -> {
return (readValue(buffer) as? List)?.let {
- FindResultState.fromList(it)
+ ReaderableState.fromList(it)
}
}
194.toByte() -> {
return (readValue(buffer) as? List)?.let {
- CustomSelectionAction.fromList(it)
+ SecurityInfoState.fromList(it)
}
}
195.toByte() -> {
return (readValue(buffer) as? List)?.let {
- WebExtensionData.fromList(it)
+ TabContentState.fromList(it)
}
}
196.toByte() -> {
return (readValue(buffer) as? List)?.let {
- GeckoSuggestion.fromList(it)
+ FindResultState.fromList(it)
}
}
197.toByte() -> {
return (readValue(buffer) as? List)?.let {
- TabContent.fromList(it)
+ CustomSelectionAction.fromList(it)
}
}
198.toByte() -> {
return (readValue(buffer) as? List)?.let {
- ContentBlocking.fromList(it)
+ WebExtensionData.fromList(it)
}
}
199.toByte() -> {
return (readValue(buffer) as? List)?.let {
- DohSettings.fromList(it)
+ AddonInfo.fromList(it)
}
}
200.toByte() -> {
return (readValue(buffer) as? List)?.let {
- GeckoEngineSettings.fromList(it)
+ AddonStoreInfo.fromList(it)
}
}
201.toByte() -> {
return (readValue(buffer) as? List)?.let {
- AutocompleteResult.fromList(it)
+ AddonUpdateAttemptInfo.fromList(it)
}
}
202.toByte() -> {
return (readValue(buffer) as? List)?.let {
- UnknownHitResult.fromList(it)
+ GeckoSuggestion.fromList(it)
}
}
203.toByte() -> {
return (readValue(buffer) as? List