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)?.let { - ImageHitResult.fromList(it) + TabContent.fromList(it) } } 204.toByte() -> { return (readValue(buffer) as? List)?.let { - VideoHitResult.fromList(it) + ContentBlocking.fromList(it) } } 205.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioHitResult.fromList(it) + DohSettings.fromList(it) } } 206.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageSrcHitResult.fromList(it) + GeckoEngineSettings.fromList(it) } } 207.toByte() -> { return (readValue(buffer) as? List)?.let { - PhoneHitResult.fromList(it) + AutocompleteResult.fromList(it) } } 208.toByte() -> { return (readValue(buffer) as? List)?.let { - EmailHitResult.fromList(it) + UnknownHitResult.fromList(it) } } 209.toByte() -> { return (readValue(buffer) as? List)?.let { - GeoHitResult.fromList(it) + ImageHitResult.fromList(it) } } 210.toByte() -> { return (readValue(buffer) as? List)?.let { - DownloadState.fromList(it) + VideoHitResult.fromList(it) } } 211.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareInternetResourceState.fromList(it) + AudioHitResult.fromList(it) } } 212.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonCollection.fromList(it) + ImageSrcHitResult.fromList(it) } } 213.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncEngineStatus.fromList(it) + PhoneHitResult.fromList(it) } } 214.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncAccountInfo.fromList(it) + EmailHitResult.fromList(it) } } 215.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDevice.fromList(it) + GeoHitResult.fromList(it) } } 216.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncIncomingTab.fromList(it) + DownloadState.fromList(it) } } 217.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncRemoteTab.fromList(it) + ShareInternetResourceState.fromList(it) } } 218.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDeviceTabs.fromList(it) + AddonCollection.fromList(it) } } 219.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoPref.fromList(it) + SyncEngineStatus.fromList(it) } } 220.toByte() -> { return (readValue(buffer) as? List)?.let { - MlProgressData.fromList(it) + SyncAccountInfo.fromList(it) } } 221.toByte() -> { return (readValue(buffer) as? List)?.let { - ContainerSiteAssignment.fromList(it) + SyncDevice.fromList(it) } } 222.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoHeader.fromList(it) + SyncIncomingTab.fromList(it) } } 223.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchRequest.fromList(it) + SyncRemoteTab.fromList(it) } } 224.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchResponse.fromList(it) + SyncDeviceTabs.fromList(it) } } 225.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkNode.fromList(it) + GeckoPref.fromList(it) } } 226.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkInfo.fromList(it) + MlProgressData.fromList(it) } } 227.toByte() -> { return (readValue(buffer) as? List)?.let { - SitePermissions.fromList(it) + ContainerSiteAssignment.fromList(it) } } 228.toByte() -> { return (readValue(buffer) as? List)?.let { - TrackingProtectionException.fromList(it) + GeckoHeader.fromList(it) } } 229.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaIcon.fromList(it) + GeckoFetchRequest.fromList(it) } } 230.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetFiles.fromList(it) + GeckoFetchResponse.fromList(it) } } 231.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetParams.fromList(it) + BookmarkNode.fromList(it) } } 232.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTarget.fromList(it) + BookmarkInfo.fromList(it) } } 233.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalApplicationResource.fromList(it) + SitePermissions.fromList(it) } } 234.toByte() -> { + return (readValue(buffer) as? List)?.let { + TrackingProtectionException.fromList(it) + } + } + 235.toByte() -> { + return (readValue(buffer) as? List)?.let { + PwaIcon.fromList(it) + } + } + 236.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetFiles.fromList(it) + } + } + 237.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetParams.fromList(it) + } + } + 238.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTarget.fromList(it) + } + } + 239.toByte() -> { + return (readValue(buffer) as? List)?.let { + ExternalApplicationResource.fromList(it) + } + } + 240.toByte() -> { return (readValue(buffer) as? List)?.let { PwaManifest.fromList(it) } @@ -5253,394 +5553,418 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(137) writeValue(stream, value.raw.toLong()) } - is GeckoSuggestionType -> { + is AddonDisabledReason -> { stream.write(138) writeValue(stream, value.raw.toLong()) } - is TrackingProtectionPolicy -> { + is AddonIncognito -> { stream.write(139) writeValue(stream, value.raw.toLong()) } - is HttpsOnlyMode -> { + is AddonUpdateStatus -> { stream.write(140) writeValue(stream, value.raw.toLong()) } - is QueryParameterStripping -> { + is GeckoSuggestionType -> { stream.write(141) writeValue(stream, value.raw.toLong()) } - is BounceTrackingProtectionMode -> { + is TrackingProtectionPolicy -> { stream.write(142) writeValue(stream, value.raw.toLong()) } - is ColorScheme -> { + is HttpsOnlyMode -> { stream.write(143) writeValue(stream, value.raw.toLong()) } - is CookieBannerHandlingMode -> { + is QueryParameterStripping -> { stream.write(144) writeValue(stream, value.raw.toLong()) } - is AppLinksMode -> { + is BounceTrackingProtectionMode -> { stream.write(145) writeValue(stream, value.raw.toLong()) } - is WebContentIsolationStrategy -> { + is ColorScheme -> { stream.write(146) writeValue(stream, value.raw.toLong()) } - is CustomCookiePolicy -> { + is CookieBannerHandlingMode -> { stream.write(147) writeValue(stream, value.raw.toLong()) } - is TrackingScope -> { + is AppLinksMode -> { stream.write(148) writeValue(stream, value.raw.toLong()) } - is DohSettingsMode -> { + is WebContentIsolationStrategy -> { stream.write(149) writeValue(stream, value.raw.toLong()) } - is DownloadStatus -> { + is CustomCookiePolicy -> { stream.write(150) writeValue(stream, value.raw.toLong()) } - is LogLevel -> { + is TrackingScope -> { stream.write(151) writeValue(stream, value.raw.toLong()) } - is SyncEngineValue -> { + is DohSettingsMode -> { stream.write(152) writeValue(stream, value.raw.toLong()) } - is MlProgressType -> { + is DownloadStatus -> { stream.write(153) writeValue(stream, value.raw.toLong()) } - is MlProgressStatus -> { + is LogLevel -> { stream.write(154) writeValue(stream, value.raw.toLong()) } - is ClearDataType -> { + is SyncEngineValue -> { stream.write(155) writeValue(stream, value.raw.toLong()) } - is GeckoFetchMethod -> { + is MlProgressType -> { stream.write(156) writeValue(stream, value.raw.toLong()) } - is GeckoFetchRedircet -> { + is MlProgressStatus -> { stream.write(157) writeValue(stream, value.raw.toLong()) } - is GeckoFetchCookiePolicy -> { + is ClearDataType -> { stream.write(158) writeValue(stream, value.raw.toLong()) } - is BookmarkNodeType -> { + is GeckoFetchMethod -> { stream.write(159) writeValue(stream, value.raw.toLong()) } - is SitePermissionStatus -> { + is GeckoFetchRedircet -> { stream.write(160) writeValue(stream, value.raw.toLong()) } - is AutoplayStatus -> { + is GeckoFetchCookiePolicy -> { stream.write(161) writeValue(stream, value.raw.toLong()) } - is TranslationOptions -> { + is BookmarkNodeType -> { stream.write(162) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationLanguage -> { + is SitePermissionStatus -> { stream.write(163) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationDetectedLanguages -> { + is AutoplayStatus -> { stream.write(164) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationPair -> { + is TranslationOptions -> { stream.write(165) writeValue(stream, value.toList()) } - is TranslationEngineStateData -> { + is TranslationLanguage -> { stream.write(166) writeValue(stream, value.toList()) } - is TabTranslationStateData -> { + is TranslationDetectedLanguages -> { stream.write(167) writeValue(stream, value.toList()) } - is ReaderState -> { + is TranslationPair -> { stream.write(168) writeValue(stream, value.toList()) } - is AddTabParams -> { + is TranslationEngineStateData -> { stream.write(169) writeValue(stream, value.toList()) } - is LastMediaAccessState -> { + is TabTranslationStateData -> { stream.write(170) writeValue(stream, value.toList()) } - is HistoryMetadataKey -> { + is ReaderState -> { stream.write(171) writeValue(stream, value.toList()) } - is PackageCategoryValue -> { + is AddTabParams -> { stream.write(172) writeValue(stream, value.toList()) } - is ExternalPackage -> { + is LastMediaAccessState -> { stream.write(173) writeValue(stream, value.toList()) } - is LoadUrlFlagsValue -> { + is HistoryMetadataKey -> { stream.write(174) writeValue(stream, value.toList()) } - is SourceValue -> { + is PackageCategoryValue -> { stream.write(175) writeValue(stream, value.toList()) } - is TabState -> { + is ExternalPackage -> { stream.write(176) writeValue(stream, value.toList()) } - is RecoverableTab -> { + is LoadUrlFlagsValue -> { stream.write(177) writeValue(stream, value.toList()) } - is IconRequest -> { + is SourceValue -> { stream.write(178) writeValue(stream, value.toList()) } - is ResourceSize -> { + is TabState -> { stream.write(179) writeValue(stream, value.toList()) } - is Resource -> { + is RecoverableTab -> { stream.write(180) writeValue(stream, value.toList()) } - is IconResult -> { + is IconRequest -> { stream.write(181) writeValue(stream, value.toList()) } - is CookiePartitionKey -> { + is ResourceSize -> { stream.write(182) writeValue(stream, value.toList()) } - is Cookie -> { + is Resource -> { stream.write(183) writeValue(stream, value.toList()) } - is VisitInfo -> { + is IconResult -> { stream.write(184) writeValue(stream, value.toList()) } - is HistoryHighlightWeights -> { + is CookiePartitionKey -> { stream.write(185) writeValue(stream, value.toList()) } - is HistoryHighlight -> { + is Cookie -> { stream.write(186) writeValue(stream, value.toList()) } - is TopFrecentSiteInfo -> { + is VisitInfo -> { stream.write(187) writeValue(stream, value.toList()) } - is HistoryItem -> { + is HistoryHighlightWeights -> { stream.write(188) writeValue(stream, value.toList()) } - is HistoryState -> { + is HistoryHighlight -> { stream.write(189) writeValue(stream, value.toList()) } - is ReaderableState -> { + is TopFrecentSiteInfo -> { stream.write(190) writeValue(stream, value.toList()) } - is SecurityInfoState -> { + is HistoryItem -> { stream.write(191) writeValue(stream, value.toList()) } - is TabContentState -> { + is HistoryState -> { stream.write(192) writeValue(stream, value.toList()) } - is FindResultState -> { + is ReaderableState -> { stream.write(193) writeValue(stream, value.toList()) } - is CustomSelectionAction -> { + is SecurityInfoState -> { stream.write(194) writeValue(stream, value.toList()) } - is WebExtensionData -> { + is TabContentState -> { stream.write(195) writeValue(stream, value.toList()) } - is GeckoSuggestion -> { + is FindResultState -> { stream.write(196) writeValue(stream, value.toList()) } - is TabContent -> { + is CustomSelectionAction -> { stream.write(197) writeValue(stream, value.toList()) } - is ContentBlocking -> { + is WebExtensionData -> { stream.write(198) writeValue(stream, value.toList()) } - is DohSettings -> { + is AddonInfo -> { stream.write(199) writeValue(stream, value.toList()) } - is GeckoEngineSettings -> { + is AddonStoreInfo -> { stream.write(200) writeValue(stream, value.toList()) } - is AutocompleteResult -> { + is AddonUpdateAttemptInfo -> { stream.write(201) writeValue(stream, value.toList()) } - is UnknownHitResult -> { + is GeckoSuggestion -> { stream.write(202) writeValue(stream, value.toList()) } - is ImageHitResult -> { + is TabContent -> { stream.write(203) writeValue(stream, value.toList()) } - is VideoHitResult -> { + is ContentBlocking -> { stream.write(204) writeValue(stream, value.toList()) } - is AudioHitResult -> { + is DohSettings -> { stream.write(205) writeValue(stream, value.toList()) } - is ImageSrcHitResult -> { + is GeckoEngineSettings -> { stream.write(206) writeValue(stream, value.toList()) } - is PhoneHitResult -> { + is AutocompleteResult -> { stream.write(207) writeValue(stream, value.toList()) } - is EmailHitResult -> { + is UnknownHitResult -> { stream.write(208) writeValue(stream, value.toList()) } - is GeoHitResult -> { + is ImageHitResult -> { stream.write(209) writeValue(stream, value.toList()) } - is DownloadState -> { + is VideoHitResult -> { stream.write(210) writeValue(stream, value.toList()) } - is ShareInternetResourceState -> { + is AudioHitResult -> { stream.write(211) writeValue(stream, value.toList()) } - is AddonCollection -> { + is ImageSrcHitResult -> { stream.write(212) writeValue(stream, value.toList()) } - is SyncEngineStatus -> { + is PhoneHitResult -> { stream.write(213) writeValue(stream, value.toList()) } - is SyncAccountInfo -> { + is EmailHitResult -> { stream.write(214) writeValue(stream, value.toList()) } - is SyncDevice -> { + is GeoHitResult -> { stream.write(215) writeValue(stream, value.toList()) } - is SyncIncomingTab -> { + is DownloadState -> { stream.write(216) writeValue(stream, value.toList()) } - is SyncRemoteTab -> { + is ShareInternetResourceState -> { stream.write(217) writeValue(stream, value.toList()) } - is SyncDeviceTabs -> { + is AddonCollection -> { stream.write(218) writeValue(stream, value.toList()) } - is GeckoPref -> { + is SyncEngineStatus -> { stream.write(219) writeValue(stream, value.toList()) } - is MlProgressData -> { + is SyncAccountInfo -> { stream.write(220) writeValue(stream, value.toList()) } - is ContainerSiteAssignment -> { + is SyncDevice -> { stream.write(221) writeValue(stream, value.toList()) } - is GeckoHeader -> { + is SyncIncomingTab -> { stream.write(222) writeValue(stream, value.toList()) } - is GeckoFetchRequest -> { + is SyncRemoteTab -> { stream.write(223) writeValue(stream, value.toList()) } - is GeckoFetchResponse -> { + is SyncDeviceTabs -> { stream.write(224) writeValue(stream, value.toList()) } - is BookmarkNode -> { + is GeckoPref -> { stream.write(225) writeValue(stream, value.toList()) } - is BookmarkInfo -> { + is MlProgressData -> { stream.write(226) writeValue(stream, value.toList()) } - is SitePermissions -> { + is ContainerSiteAssignment -> { stream.write(227) writeValue(stream, value.toList()) } - is TrackingProtectionException -> { + is GeckoHeader -> { stream.write(228) writeValue(stream, value.toList()) } - is PwaIcon -> { + is GeckoFetchRequest -> { stream.write(229) writeValue(stream, value.toList()) } - is ShareTargetFiles -> { + is GeckoFetchResponse -> { stream.write(230) writeValue(stream, value.toList()) } - is ShareTargetParams -> { + is BookmarkNode -> { stream.write(231) writeValue(stream, value.toList()) } - is ShareTarget -> { + is BookmarkInfo -> { stream.write(232) writeValue(stream, value.toList()) } - is ExternalApplicationResource -> { + is SitePermissions -> { stream.write(233) writeValue(stream, value.toList()) } - is PwaManifest -> { + is TrackingProtectionException -> { stream.write(234) writeValue(stream, value.toList()) } + is PwaIcon -> { + stream.write(235) + writeValue(stream, value.toList()) + } + is ShareTargetFiles -> { + stream.write(236) + writeValue(stream, value.toList()) + } + is ShareTargetParams -> { + stream.write(237) + writeValue(stream, value.toList()) + } + is ShareTarget -> { + stream.write(238) + writeValue(stream, value.toList()) + } + is ExternalApplicationResource -> { + stream.write(239) + writeValue(stream, value.toList()) + } + is PwaManifest -> { + stream.write(240) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -8159,10 +8483,21 @@ class GeckoSelectionActionEvents(private val binaryMessenger: BinaryMessenger, p } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoAddonsApi { - fun startAddonManagerActivity() - fun startAddonSettingsActivity(extensionId: String) + fun getAddons(allowCache: Boolean, callback: (Result>) -> Unit) + fun getAddonById(addonId: String, allowCache: Boolean, callback: (Result) -> Unit) + fun getAddonStoreInfo(addonId: String, callback: (Result) -> Unit) fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) + fun enableAddon(addonId: String, callback: (Result) -> Unit) + fun disableAddon(addonId: String, callback: (Result) -> Unit) + fun setAddonAllowedInPrivateBrowsing(addonId: String, allowed: Boolean, callback: (Result) -> Unit) + fun setAddonAutoUpdateEnabledForAddon(addonId: String, enabled: Boolean, callback: (Result) -> Unit) + fun uninstallAddon(addonId: String, callback: (Result) -> Unit) + fun triggerAddonUpdate(addonId: String, callback: (Result) -> Unit) + fun triggerAllAddonUpdates(callback: (Result) -> Unit) + fun getLastAddonUpdateAttempt(addonId: String, callback: (Result) -> Unit) fun installAddon(url: String, callback: (Result) -> Unit) + fun isAddonAutoUpdateEnabled(callback: (Result) -> Unit) + fun setAddonAutoUpdateEnabled(enabled: Boolean, callback: (Result) -> Unit) companion object { /** The codec used by GeckoAddonsApi. */ @@ -8174,34 +8509,61 @@ interface GeckoAddonsApi { fun setUp(binaryMessenger: BinaryMessenger, api: GeckoAddonsApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.startAddonManagerActivity$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.getAddons$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.startAddonManagerActivity() - listOf(null) - } catch (exception: Throwable) { - GeckoPigeonUtils.wrapError(exception) + channel.setMessageHandler { message, reply -> + val args = message as List + val allowCacheArg = args[0] as Boolean + api.getAddons(allowCacheArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } } - reply.reply(wrapped) } } else { channel.setMessageHandler(null) } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.startAddonSettingsActivity$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.getAddonById$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List - val extensionIdArg = args[0] as String - val wrapped: List = try { - api.startAddonSettingsActivity(extensionIdArg) - listOf(null) - } catch (exception: Throwable) { - GeckoPigeonUtils.wrapError(exception) + val addonIdArg = args[0] as String + val allowCacheArg = args[1] as Boolean + api.getAddonById(addonIdArg, allowCacheArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.getAddonStoreInfo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.getAddonStoreInfo(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } } - reply.reply(wrapped) } } else { channel.setMessageHandler(null) @@ -8226,6 +8588,164 @@ interface GeckoAddonsApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.enableAddon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.enableAddon(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.disableAddon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.disableAddon(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.setAddonAllowedInPrivateBrowsing$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + val allowedArg = args[1] as Boolean + api.setAddonAllowedInPrivateBrowsing(addonIdArg, allowedArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.setAddonAutoUpdateEnabledForAddon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + val enabledArg = args[1] as Boolean + api.setAddonAutoUpdateEnabledForAddon(addonIdArg, enabledArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.uninstallAddon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.uninstallAddon(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.triggerAddonUpdate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.triggerAddonUpdate(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.triggerAllAddonUpdates$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.triggerAllAddonUpdates{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.getLastAddonUpdateAttempt$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val addonIdArg = args[0] as String + api.getLastAddonUpdateAttempt(addonIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.installAddon$separatedMessageChannelSuffix", codec) if (api != null) { @@ -8245,6 +8765,43 @@ interface GeckoAddonsApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.isAddonAutoUpdateEnabled$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.isAddonAutoUpdateEnabled{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.setAddonAutoUpdateEnabled$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enabledArg = args[0] as Boolean + api.setAddonAutoUpdateEnabled(enabledArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -8307,6 +8864,23 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val } } } + fun onWebExtensionPopupRequested(extensionIdArg: String, extensionNameArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onWebExtensionPopupRequested$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(extensionIdArg, extensionNameArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoSuggestionApi { diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml deleted file mode 100644 index a26114bb..00000000 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml deleted file mode 100644 index dd27f3c1..00000000 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml deleted file mode 100644 index 858a096d..00000000 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml deleted file mode 100644 index 0325acee..00000000 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - -