new extension management

This commit is contained in:
Fabian Freund
2026-04-17 19:09:27 +02:00
parent 8217472f59
commit f9bcd076f2
49 changed files with 7827 additions and 4533 deletions
@@ -209,38 +209,6 @@
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity" android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
android:exported="false" /> android:exported="false" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:theme="@style/AppTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
android:exported="false"
android:label="@string/mozac_feature_addons_addons" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/AppTheme" />
<activity <activity
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity" android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
android:label="@string/mozac_feature_addons_addons" android:label="@string/mozac_feature_addons_addons"
@@ -34,8 +34,4 @@
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
</style> </style>
<style name="AddonsActivityTheme" parent="AppTheme">
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
</style>
</resources> </resources>
@@ -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 <http://www.gnu.org/licenses/>.
*/
part of 'routes.dart';
@TypedGoRoute<AddonManagerRoute>(
name: 'AddonManagerRoute',
path: '/addons',
routes: [
TypedGoRoute<AddonDetailsRoute>(
name: 'AddonDetailsRoute',
path: 'details/:addonId',
),
TypedGoRoute<AddonPermissionsRoute>(
name: 'AddonPermissionsRoute',
path: 'permissions/:addonId',
),
TypedGoRoute<AddonInternalSettingsRoute>(
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);
}
}
@@ -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/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/about/presentation/screens/about.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/data/models/bang.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart'; import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
import 'package:weblibre/features/bangs/presentation/screens/category.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.bangs.dart';
part 'routes.bookmarks.dart'; part 'routes.bookmarks.dart';
part 'routes.browser.dart'; part 'routes.browser.dart';
part 'routes.addons.dart';
part 'routes.feeds.dart'; part 'routes.feeds.dart';
part 'routes.g.dart'; part 'routes.g.dart';
part 'routes.history.dart'; part 'routes.history.dart';
@@ -13,6 +13,7 @@ List<RouteBase> get $appRoutes => [
$bangMenuRoute, $bangMenuRoute,
$bookmarksRoute, $bookmarksRoute,
$browserRoute, $browserRoute,
$addonManagerRoute,
$feedListRoute, $feedListRoute,
$historyRoute, $historyRoute,
$profileListRoute, $profileListRoute,
@@ -931,6 +932,125 @@ extension<T extends Enum> on Map<T, String> {
entries.where((element) => element.value == value).firstOrNull?.key; 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<T?> push<T>(BuildContext context) => context.push<T>(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<T?> push<T>(BuildContext context) => context.push<T>(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<T?> push<T>(BuildContext context) => context.push<T>(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<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $feedListRoute => GoRouteData.$route( RouteBase get $feedListRoute => GoRouteData.$route(
path: '/feeds', path: '/feeds',
name: 'FeedListRoute', name: 'FeedListRoute',
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> _run(Future<AddonInfo?> Function() action) async {
state = const AsyncLoading<AddonInfo?>();
state = await AsyncValue.guard(action);
ref.invalidate(addonListProvider);
}
Future<void> refresh() async {
ref.invalidateSelf();
await future;
}
Future<void> 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<void> uninstall() async {
await _run(() async {
await _service.uninstallAddon(addonId);
return null;
});
}
Future<void> setEnabled({required bool enabled}) async {
await _run(
() => enabled
? _service.enableAddon(addonId)
: _service.disableAddon(addonId),
);
}
Future<void> setAllowedInPrivateBrowsing({required bool allowed}) async {
await _run(
() => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed),
);
}
Future<void> setAutoUpdateEnabled({required bool enabled}) async {
await _run(
() => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled),
);
}
@override
Future<AddonInfo?> build(String addonId) {
return _service.getAddonById(addonId);
}
}
@Riverpod()
Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String addonId) {
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
}
@Riverpod()
Future<AddonUpdateAttemptInfo?> 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<AddonUpdateOutcome> 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<AddonUpdateRunResult> triggerAndAwait() async {
state = const AsyncLoading();
final result = await AsyncValue.guard<AddonUpdateRunResult>(() 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<AddonUpdateRunResult> build(String addonId) =>
const AsyncData(AddonUpdateRunDone(null));
}
@Riverpod()
class AddonList extends _$AddonList {
GeckoAddonService get _service => ref.read(addonServiceProvider);
Future<void> refresh() async {
ref.invalidateSelf();
await future;
}
Future<void> 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<void> 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<List<AddonInfo>> 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<String> 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<String> build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'PinnedAddonIds',
encode: (state) => jsonEncode(state.toList()),
decode: (encoded) =>
(jsonDecode(encoded) as List<dynamic>).cast<String>().toSet(),
);
return stateOrNull ?? const {};
}
}
@Riverpod()
class BulkAddonUpdate extends _$BulkAddonUpdate {
Future<void> triggerAll() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(addonServiceProvider).triggerAllAddonUpdates();
});
}
@override
AsyncValue<void> build() => const AsyncData(null);
}
@@ -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<AddonDetails, AddonInfo?> {
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?>,
AddonInfo?,
FutureOr<AddonInfo?>,
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<AddonInfo?> {
late final _$args = ref.$arg as String;
String get addonId => _$args;
FutureOr<AddonInfo?> build(String addonId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<AddonInfo?>, AddonInfo?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AddonInfo?>, AddonInfo?>,
AsyncValue<AddonInfo?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(addonStoreInfo)
final addonStoreInfoProvider = AddonStoreInfoFamily._();
final class AddonStoreInfoProvider
extends
$FunctionalProvider<
AsyncValue<AddonStoreInfo?>,
AddonStoreInfo?,
FutureOr<AddonStoreInfo?>
>
with $FutureModifier<AddonStoreInfo?>, $FutureProvider<AddonStoreInfo?> {
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<AddonStoreInfo?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddonStoreInfo?> 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<FutureOr<AddonStoreInfo?>, 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?>,
AddonUpdateAttemptInfo?,
FutureOr<AddonUpdateAttemptInfo?>
>
with
$FutureModifier<AddonUpdateAttemptInfo?>,
$FutureProvider<AddonUpdateAttemptInfo?> {
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<AddonUpdateAttemptInfo?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddonUpdateAttemptInfo?> 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<FutureOr<AddonUpdateAttemptInfo?>, 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<AddonUpdateCheck, AsyncValue<AddonUpdateRunResult>> {
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<AddonUpdateRunResult> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<AddonUpdateRunResult>>(
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<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>,
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<AsyncValue<AddonUpdateRunResult>> {
late final _$args = ref.$arg as String;
String get addonId => _$args;
AsyncValue<AddonUpdateRunResult> build(String addonId);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>
>,
AsyncValue<AddonUpdateRunResult>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(AddonList)
final addonListProvider = AddonListProvider._();
final class AddonListProvider
extends $AsyncNotifierProvider<AddonList, List<AddonInfo>> {
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<List<AddonInfo>> {
FutureOr<List<AddonInfo>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<List<AddonInfo>>, List<AddonInfo>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<AddonInfo>>, List<AddonInfo>>,
AsyncValue<List<AddonInfo>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(AddonBusyIds)
final addonBusyIdsProvider = AddonBusyIdsProvider._();
final class AddonBusyIdsProvider
extends $NotifierProvider<AddonBusyIds, Set<String>> {
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<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$addonBusyIdsHash() => r'6f9761320d42b2b5132936797617fdb13b718dd3';
abstract class _$AddonBusyIds extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(PinnedAddonIds)
final pinnedAddonIdsProvider = PinnedAddonIdsProvider._();
final class PinnedAddonIdsProvider
extends $NotifierProvider<PinnedAddonIds, Set<String>> {
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<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$pinnedAddonIdsHash() => r'4f46cd69d4817e6e19e4d04f4fdcf83d5e2efb8c';
abstract class _$PinnedAddonIds extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BulkAddonUpdate)
final bulkAddonUpdateProvider = BulkAddonUpdateProvider._();
final class BulkAddonUpdateProvider
extends $NotifierProvider<BulkAddonUpdate, AsyncValue<void>> {
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<void> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
);
}
}
String _$bulkAddonUpdateHash() => r'2605711734f9eb6af70e721a4337556a7ea85512';
abstract class _$BulkAddonUpdate extends $Notifier<AsyncValue<void>> {
AsyncValue<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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,
};
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void>(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<void>(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<bool?> _showConfirmUninstallDialog(
BuildContext context,
AddonInfo addon,
) {
return showDialog<bool>(
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<void> _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<bool?> _confirmUpdateDialog(
BuildContext context,
AddonInfo addon,
String availableVersion,
) {
return showDialog<bool>(
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)),
),
],
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> openAddonSettingsFlow(
BuildContext context,
WidgetRef ref,
AddonInfo addon,
) async {
if (!addon.hasOptionsPage) {
await const AddonManagerRoute().push<void>(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<void>(context);
}
Future<void> 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<void>(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 <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (params) {
final controller = PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: 'eu.weblibre/addon_settings',
layoutDirection: TextDirection.ltr,
creationParams: <String, Object?>{'optionsPageUrl': optionsPageUrl},
creationParamsCodec: const StandardMessageCodec(),
);
controller.addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
);
unawaited(controller.create());
return controller;
},
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<AddonInfo> 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<void>(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')),
],
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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() ?? <String>[];
permissions.sort();
final dataCollection =
addon?.translatedRequiredDataCollectionPermissions.toList() ??
<String>[];
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'),
),
],
),
},
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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',
};
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void>(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 8.0,
),
child: ExtensionBadgeIcon(extension),
),
),
],
),
);
}
}
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
@override @override
FutureOr<bool> build() async { FutureOr<bool> build() async {
final prefs = final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
final pref = prefs[_signatureRequiredPref]; final pref = prefs[_signatureRequiredPref];
final allowUnsigned = pref?.value == false; final allowUnsigned = pref?.value == false;
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
} }
} }
@Riverpod(keepAlive: true)
class AddonAutoUpdate extends _$AddonAutoUpdate {
Future<void> setEnabled({required bool enabled}) async {
final service = ref.read(addonServiceProvider);
await service.setAddonAutoUpdateEnabled(enabled: enabled);
state = AsyncData(enabled);
}
@override
FutureOr<bool> build() {
return ref.read(addonServiceProvider).isAddonAutoUpdateEnabled();
}
}
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService { class BrowserAddonService extends _$BrowserAddonService {
Future<Uri> getAddonXpiUrl(String guid) async { Future<Uri> getAddonXpiUrl(String guid) async {
@@ -54,6 +54,50 @@ abstract class _$AllowUnsignedExtensions extends $AsyncNotifier<bool> {
} }
} }
@ProviderFor(AddonAutoUpdate)
final addonAutoUpdateProvider = AddonAutoUpdateProvider._();
final class AddonAutoUpdateProvider
extends $AsyncNotifierProvider<AddonAutoUpdate, bool> {
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<bool> {
FutureOr<bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, bool>,
AsyncValue<bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BrowserAddonService) @ProviderFor(BrowserAddonService)
final browserAddonServiceProvider = BrowserAddonServiceProvider._(); final browserAddonServiceProvider = BrowserAddonServiceProvider._();
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
.installFromFile(selectedFile.value!); .installFromFile(selectedFile.value!);
if (context.mounted) { if (context.mounted) {
showInfoMessage(context, 'Extension installed successfully'); showInfoMessage(
context,
'Extension installed. Automatic updates are disabled for this local version.',
);
context.pop(true); context.pop(true);
} }
} catch (e) { } 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) ...[ if (errorMessage.value != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -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/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/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/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/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_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.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) { useOnAppLifecycleStateChange((previous, current) {
switch (current) { switch (current) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> showAddonPopupBottomSheet(
BuildContext context, {
required String extensionId,
required String extensionName,
}) {
return showModalBottomSheet<void>(
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<OneSequenceGestureRecognizer>>{
Factory<EagerGestureRecognizer>(EagerGestureRecognizer.new),
},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (params) {
final controller = PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: _viewType,
layoutDirection: TextDirection.ltr,
creationParams: <String, Object?>{'extensionId': extensionId},
creationParamsCodec: const StandardMessageCodec(),
);
controller.addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
);
unawaited(controller.create());
return controller;
},
);
}
}
@@ -36,6 +36,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/providers/persisted_bool.dart'; import 'package:weblibre/core/providers/persisted_bool.dart';
import 'package:weblibre/core/routing/routes.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/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
WebExtensionActionType.browser, WebExtensionActionType.browser,
).select((value) => value.values.toList()), ).select((value) => value.values.toList()),
); );
final rootContext = Navigator.of(context, rootNavigator: true).context;
Future<void> openExtensionSettings(String extensionId) async { Future<void> openExtensionSettings(String extensionId) async {
Navigator.pop(context); Navigator.pop(context);
await addonService.startAddonSettingsActivity(extensionId); await openAddonSettingsFlowById(rootContext, ref, extensionId);
} }
return _buildMenuCard( return _buildMenuCard(
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
icon: MdiIcons.puzzleEdit, icon: MdiIcons.puzzleEdit,
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);
await addonService.startAddonManagerActivity(); await const AddonManagerRoute().push<void>(rootContext);
}, },
), ),
_buildSubTile( _buildSubTile(
@@ -27,6 +27,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.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/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
: const AppBarTitle() : const AppBarTitle()
: null, : null,
actions: [ actions: [
const PinnedAddonBar(),
if (isSmallWebMode) if (isSmallWebMode)
ReaderButton( ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton( buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [ children: const [
SettingSection(name: 'Extensions'), SettingSection(name: 'Extensions'),
_ManageExtensionsTile(),
_InstallLocalAddonTile(), _InstallLocalAddonTile(),
_AddonCollectionTile(), _AddonCollectionTile(),
SettingSection(name: 'Updates'),
_AutoUpdateTile(),
SettingSection(name: 'Security'), SettingSection(name: 'Security'),
_AllowUnsignedExtensionsTile(), _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<void>(context);
},
icon: const Icon(Icons.open_in_new),
label: const Text('Open'),
),
);
}
}
class _InstallLocalAddonTile extends StatelessWidget { class _InstallLocalAddonTile extends StatelessWidget {
const _InstallLocalAddonTile(); 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 { class _AllowUnsignedExtensionsTile extends ConsumerWidget {
const _AllowUnsignedExtensionsTile(); const _AllowUnsignedExtensionsTile();
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider); final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
return Column( return allowUnsigned.when(
children: [ data: (allowed) => Column(
SwitchListTile.adaptive( children: [
title: const Text('Allow unsigned extensions'), SwitchListTile.adaptive(
subtitle: const Text( title: const Text('Allow unsigned extensions'),
'Unsigned extensions have not been verified by Mozilla', subtitle: const Text(
), 'Unsigned extensions have not been verified by Mozilla',
secondary: const Icon(Icons.extension_off), ),
value: allowUnsigned.value ?? false, secondary: const Icon(Icons.extension_off),
onChanged: allowUnsigned.isLoading value: allowed,
? null onChanged: (value) async {
: (value) async { if (value) {
if (value) { final confirmed = await _showAllowUnsignedConfirmationDialog(
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(
context, context,
).colorScheme.errorContainer.withValues(alpha: 0.5), );
borderRadius: BorderRadius.circular(8), if (confirmed != true) return;
border: Border.all(color: Theme.of(context).colorScheme.error), }
), await ref
child: Row( .read(allowUnsignedExtensionsProvider.notifier)
children: [ .setAllowUnsigned(allow: value);
Icon( },
Icons.warning_amber, ),
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, color: Theme.of(context).colorScheme.error,
size: 20,
), ),
const SizedBox(width: 8), ),
Expanded( child: Row(
child: Text( children: [
'Only install unsigned extensions from sources you trust. ' Icon(
'They may contain malicious code.', Icons.warning_amber,
style: TextStyle( color: Theme.of(context).colorScheme.error,
color: Theme.of(context).colorScheme.onErrorContainer, size: 20,
fontSize: 12, ),
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 {
); );
} }
} }
@@ -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<View>(containerId) == null) {
return
}
val existingFragment = fm.findFragmentByTag(fragmentTag)
if (existingFragment is FlutterAddonSettingsFragment) {
return
}
fm.beginTransaction()
.replace(containerId, FlutterAddonSettingsFragment.create(optionsPageUrl), fragmentTag)
.commitNow()
}
}
@@ -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<View>(containerId) == null) {
return
}
val existingFragment = fm.findFragmentByTag(fragmentTag)
if (existingFragment is FlutterAddonPopupFragment) {
return
}
fm.beginTransaction()
.replace(containerId, FlutterAddonPopupFragment.create(extensionId), fragmentTag)
.commitNow()
}
}
@@ -22,7 +22,6 @@ import androidx.annotation.CallSuper
import androidx.core.content.edit import androidx.core.content.edit
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager 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.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
@@ -555,14 +554,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
protected open fun onEngineSetupComplete() {} protected open fun onEngineSetupComplete() {}
private fun openPopup(webExtensionState: WebExtensionState) { private fun openPopup(webExtensionState: WebExtensionState) {
val intent = Intent( components.addonEvents.onWebExtensionPopupRequested(
components.profileApplicationContext, webExtensionState.id,
WebExtensionActionPopupActivity::class.java webExtensionState.name ?: "",
) ) {}
intent.putExtra("web_extension_id", webExtensionState.id)
intent.putExtra("web_extension_name", webExtensionState.name)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} }
@CallSuper @CallSuper
@@ -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.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController 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.GeckoViewportApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
@@ -268,7 +269,23 @@ object GlobalComponents {
}, },
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest, onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
onExtensionsLoaded = { extensions -> 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() newComponents.core.supportedAddonsChecker.registerForChecks()
}, },
) )
@@ -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<RatingBar>(R.id.rating_view)
val userCountView = findViewById<TextView>(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<View>(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<TextView>(R.id.last_updated_text)
lastUpdatedView.text = formatDate(addon.updatedAt)
}
private fun bindVersion(addon: Addon) {
val versionView = findViewById<TextView>(R.id.version_text)
versionView.text = addon.version
}
private fun bindAuthor(addon: Addon) {
val authorsView = findViewById<TextView>(R.id.author_text)
authorsView.text = addon.author?.name.orEmpty()
}
private fun bindDetails(addon: Addon) {
val detailsView = findViewById<TextView>(R.id.details)
val detailsText = addon.translateDescription(this)
val parsedText = detailsText.replace("\n", "<br/>")
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)!!)
}
}
@@ -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<Addon>("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<View>(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)
}
}
}
}
}
@@ -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)
}
@@ -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
}
}
@@ -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<WebExtensionPromptFeature>()
private lateinit var recyclerView: RecyclerView
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var addons: List<Addon>
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
}
@@ -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)
}
}
}
}
@@ -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)
}
}
}
}
@@ -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<SwitchCompat>(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<View>(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<View>(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<View>(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<SwitchCompat>(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<View>(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()
}
@@ -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<RecyclerView>(R.id.add_ons_permissions)
recyclerView.layoutManager = LinearLayoutManager(this)
val sortedPermissions = addon.translatePermissions(this).sorted()
recyclerView.adapter = PermissionsAdapter(sortedPermissions)
}
private fun bindLearnMore() {
findViewById<View>(R.id.learn_more_label).setOnClickListener(this)
}
/**
* An adapter for displaying the permissions of an add-on.
*/
class PermissionsAdapter(
private val permissions: List<String>,
) : RecyclerView.Adapter<PermissionViewHolder>() {
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<TextView>(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)
}
}
@@ -7,102 +7,767 @@
package eu.weblibre.flutter_mozilla_components.api package eu.weblibre.flutter_mozilla_components.api
import android.content.Context import android.content.Context
import android.content.Intent
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity 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.GeckoAddonsApi
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.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 { class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
private val components by lazy { private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" } requireNotNull(GlobalComponents.components) { "Components not initialized" }
} }
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val updateAttemptStorage by lazy {
override fun startAddonManagerActivity() { DefaultAddonUpdater.UpdateAttemptStorage(context.applicationContext)
val intent = Intent(context, AddonsActivity::class.java) }
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK private val prefs by lazy {
context.startActivity(intent) 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<List<AddonInfo>>) -> Unit) {
scope.launch { scope.launch {
val addon = runCatching { runCatching {
components.core.addonManager.getAddons() components.core.addonManager.getAddons(allowCache = allowCache)
.find { it.id == extensionId } .map { addon ->
}.getOrNull() addon.toPigeon(
context = context,
if (addon == null) { isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
withContext(Dispatchers.Main) { isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
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()
} }
} else { }.fold(
val intent = Intent(context, AddonInternalSettingsActivity::class.java) onSuccess = { callback(Result.success(it)) },
intent.putExtra("add_on", addon) onFailure = { callback(Result.failure(it)) },
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK )
context.startActivity(intent) }
} }
}
override fun getAddonById(
addonId: String,
allowCache: Boolean,
callback: (Result<AddonInfo?>) -> 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<AddonStoreInfo?>) -> Unit) {
scope.launch {
runCatching {
fetchAddonStoreInfo(addonId)
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
} }
} }
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) { override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
when(actionType) { scope.launch {
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId) withContext(Dispatchers.Main.immediate) {
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId) when(actionType) {
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
}
}
}
}
override fun enableAddon(addonId: String, callback: (Result<AddonInfo>) -> 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<AddonInfo>) -> 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<AddonInfo>) -> 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<AddonInfo>) -> 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>) -> 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<AddonUpdateAttemptInfo?>) -> 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>) -> 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<AddonUpdateAttemptInfo?>) -> 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<Boolean>) -> Unit) {
callback(Result.success(prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)))
}
override fun setAddonAutoUpdateEnabled(enabled: Boolean, callback: (Result<Unit>) -> 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>) -> Unit) { override fun installAddon(url: String, callback: (Result<Unit>) -> Unit) {
val installMethod = if (url.startsWith("file://")) { val isLocalFileInstall = url.startsWith("file://")
val installMethod = if (isLocalFileInstall) {
InstallationMethod.FROM_FILE InstallationMethod.FROM_FILE
} else { } else {
null null
} }
components.core.addonManager.installAddon( scope.launch {
url = url, try {
installationMethod = installMethod, withContext(Dispatchers.Main.immediate) {
onSuccess = { _ -> performAddonInstall(url, isLocalFileInstall, callback)
callback(Result.success(Unit)) }
}, } catch (throwable: Throwable) {
onError = { e -> callback(Result.failure(throwable))
callback(Result.failure(e))
} }
}
}
private fun <T> withInstalledAddon(
addonId: String,
callback: (Result<T>) -> Unit,
block: suspend (Addon, (Result<T>) -> 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<String> {
return prefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet()) ?: emptySet()
}
private fun getLocalFileAddonIds(): Set<String> {
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>) -> 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<AddonUpdateAttemptInfo?>) -> 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
}
} }
@@ -10,6 +10,8 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.view.View import android.view.View
import androidx.fragment.app.FragmentActivity 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.BrowserFragment
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
import eu.weblibre.flutter_mozilla_components.EngineProvider import eu.weblibre.flutter_mozilla_components.EngineProvider
@@ -145,6 +147,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
_flutterEvents _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 isPlatformViewRegistered = true
isGeckoInitialized = false isGeckoInitialized = false
@@ -1,157 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<TextView
android:id="@+id/details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
tools:text="@tools:sample/lorem/random" />
<TextView
android:id="@+id/author_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:text="@string/mozac_feature_addons_author" />
<TextView
android:id="@+id/author_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:layout_alignParentEnd="true"
tools:text="@tools:sample/full_names" />
<View
android:id="@+id/author_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/author_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/version_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/author_divider"
android:text="@string/mozac_feature_addons_version" />
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/author_divider"
android:layout_alignParentEnd="true"
tools:text="1.2.3" />
<View
android:id="@+id/version_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/version_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/last_updated_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/version_divider"
android:text="@string/mozac_feature_addons_last_updated" />
<TextView
android:id="@+id/last_updated_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/version_divider"
android:layout_alignParentEnd="true"
tools:text="Oct 16, 2019" />
<View
android:id="@+id/last_updated_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/last_updated_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/home_page_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/last_updated_divider"
android:text="@string/mozac_feature_addons_home_page" />
<ImageView
android:id="@+id/home_page_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/last_updated_divider"
android:layout_alignParentEnd="true"
android:contentDescription="@string/mozac_feature_addons_home_page"
android:src="@drawable/mozac_ic_link_24"
app:tint="@color/icons" />
<View
android:id="@+id/home_page_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/home_page_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/rating_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/home_page_divider"
android:text="@string/mozac_feature_addons_rating" />
<RatingBar
android:id="@+id/rating_view"
style="@style/Widget.AppCompat.RatingBar.Small"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_below="@+id/home_page_divider"
android:layout_toStartOf="@+id/users_count"
android:isIndicator="true"
android:numStars="5" />
<TextView
android:id="@+id/users_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/home_page_divider"
android:layout_alignParentEnd="true"
android:layout_marginStart="6dp"
tools:text="591,642" />
</RelativeLayout>
</ScrollView>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true" >
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".addons.PermissionsDetailsActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/add_ons_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/learn_more_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/add_ons_permissions"
app:drawableEndCompat="@drawable/mozac_ic_link_24"
android:padding="16dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:background="?attr/selectableItemBackground"
android:text="@string/mozac_feature_addons_learn_more"
app:drawableTint="@color/icons" />
</RelativeLayout>
@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="6dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/enable_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:background="?android:attr/selectableItemBackground"
android:checked="true"
android:clickable="true"
android:focusable="true"
android:text="@string/mozac_feature_addons_enabled"
android:padding="16dp"
android:textSize="18sp"/>
<TextView
android:id="@+id/settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/enable_switch"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="10dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_settings"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_preferences"
app:drawableTint="@color/icons" />
<TextView
android:id="@+id/details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/settings"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="6dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_details"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_information_24"
app:drawableTint="@color/icons" />
<TextView
android:id="@+id/permissions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="6dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_permissions"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_permissions" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/allow_in_private_browsing_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_below="@+id/permissions"
android:background="?android:attr/selectableItemBackground"
android:checked="false"
android:clickable="true"
android:focusable="true"
android:text="@string/mozac_feature_addons_settings_allow_in_private_browsing_2"
android:padding="16dp"
android:textSize="18sp"/>
<Button
android:id="@+id/remove_add_on"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/allow_in_private_browsing_switch"
android:layout_marginTop="16dp"
android:textColor="@color/photonRed50"
android:text="@string/mozac_feature_addons_remove" />
</RelativeLayout>
</ScrollView>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/add_on_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp"
tools:text="Access your data for all websites" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
</LinearLayout>
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/add_ons_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".BrowserActivity"/>
<include
android:id="@+id/addonProgressOverlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:visibility="gone"
layout="@layout/overlay_add_on_progress" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -66,37 +66,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:theme="@style/Theme.AppCompat.Light"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:theme="@style/Theme.AppCompat.Light"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
android:exported="false"
android:label="@string/mozac_feature_addons_addons" />
<activity android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity <activity
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity" android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
android:label="@string/mozac_feature_addons_addons" android:label="@string/mozac_feature_addons_addons"
@@ -38,6 +38,12 @@ export 'src/pigeons/gecko.g.dart'
show show
AddTabParams, AddTabParams,
AddonCollection, AddonCollection,
AddonDisabledReason,
AddonIncognito,
AddonInfo,
AddonStoreInfo,
AddonUpdateAttemptInfo,
AddonUpdateStatus,
AppLinksMode, AppLinksMode,
AudioHitResult, AudioHitResult,
AutoplayStatus, AutoplayStatus,
@@ -13,6 +13,7 @@ import 'package:rxdart/rxdart.dart';
typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data}); typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data});
typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes}); typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes});
typedef ExtensionPopupEvent = ({String extensionId, String extensionName});
final _apiInstance = GeckoAddonsApi(); final _apiInstance = GeckoAddonsApi();
@@ -24,6 +25,7 @@ class GeckoAddonService extends GeckoAddonEvents {
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>(); final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>(); final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
final _popupSubject = PublishSubject<ExtensionPopupEvent>();
Stream<ExtensionDataEvent> get browserExtensionStream => Stream<ExtensionDataEvent> get browserExtensionStream =>
_browserExtensionSubject.stream; _browserExtensionSubject.stream;
@@ -33,13 +35,18 @@ class GeckoAddonService extends GeckoAddonEvents {
Stream<ExtensionIconEvent> get browserIconStream => Stream<ExtensionIconEvent> get browserIconStream =>
_browserIconSubject.stream; _browserIconSubject.stream;
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream; Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
Future<void> startAddonManagerActivity() { Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
return _api.startAddonManagerActivity(); return _api.getAddons(allowCache);
} }
Future<void> startAddonSettingsActivity(String extensionId) { Future<AddonInfo?> getAddonById(String addonId, {bool allowCache = true}) {
return _api.startAddonSettingsActivity(extensionId); return _api.getAddonById(addonId, allowCache);
}
Future<AddonStoreInfo?> getAddonStoreInfo(String addonId) {
return _api.getAddonStoreInfo(addonId);
} }
Future<void> invokeAddonAction( Future<void> invokeAddonAction(
@@ -53,6 +60,52 @@ class GeckoAddonService extends GeckoAddonEvents {
return _api.installAddon(url.toString()); return _api.installAddon(url.toString());
} }
Future<AddonInfo> enableAddon(String addonId) {
return _api.enableAddon(addonId);
}
Future<AddonInfo> disableAddon(String addonId) {
return _api.disableAddon(addonId);
}
Future<AddonInfo> setAddonAllowedInPrivateBrowsing(
String addonId,
bool allowed,
) {
return _api.setAddonAllowedInPrivateBrowsing(addonId, allowed);
}
Future<AddonInfo> setAddonAutoUpdateEnabledForAddon(
String addonId,
bool enabled,
) {
return _api.setAddonAutoUpdateEnabledForAddon(addonId, enabled);
}
Future<void> uninstallAddon(String addonId) {
return _api.uninstallAddon(addonId);
}
Future<AddonUpdateAttemptInfo?> triggerAddonUpdate(String addonId) {
return _api.triggerAddonUpdate(addonId);
}
Future<void> triggerAllAddonUpdates() {
return _api.triggerAllAddonUpdates();
}
Future<AddonUpdateAttemptInfo?> getLastAddonUpdateAttempt(String addonId) {
return _api.getLastAddonUpdateAttempt(addonId);
}
Future<bool> isAddonAutoUpdateEnabled() {
return _api.isAddonAutoUpdateEnabled();
}
Future<void> setAddonAutoUpdateEnabled({required bool enabled}) {
return _api.setAddonAutoUpdateEnabled(enabled);
}
@override @override
void onRemoveWebExtensionAction( void onRemoveWebExtensionAction(
int sequence, int sequence,
@@ -115,6 +168,11 @@ class GeckoAddonService extends GeckoAddonEvents {
} }
} }
@override
void onWebExtensionPopupRequested(String extensionId, String extensionName) {
_popupSubject.add((extensionId: extensionId, extensionName: extensionName));
}
GeckoAddonService.setUp({ GeckoAddonService.setUp({
BinaryMessenger? binaryMessenger, BinaryMessenger? binaryMessenger,
GeckoAddonsApi? api, GeckoAddonsApi? api,
@@ -132,5 +190,6 @@ class GeckoAddonService extends GeckoAddonEvents {
await _pageExtensionSubject.close(); await _pageExtensionSubject.close();
await _browserIconSubject.close(); await _browserIconSubject.close();
await _pageIconSubject.close(); await _pageIconSubject.close();
await _popupSubject.close();
} }
} }
File diff suppressed because it is too large Load Diff
@@ -667,6 +667,112 @@ class WebExtensionData {
); );
} }
enum AddonDisabledReason {
unsupported,
blocklisted,
userRequested,
notCorrectlySigned,
incompatible,
softBlocked,
}
enum AddonIncognito { spanning, split, notAllowed }
enum AddonUpdateStatus {
notInstalled,
successfullyUpdated,
noUpdateAvailable,
error,
}
class AddonInfo {
final String id;
final String displayName;
final String? summary;
final String description;
final String downloadUrl;
final String version;
final String? installedVersion;
final List<String> translatedPermissions;
final List<String> translatedRequiredDataCollectionPermissions;
final String? authorName;
final String? authorUrl;
final String homepageUrl;
final String detailUrl;
final String ratingUrl;
final double? ratingAverage;
final int? ratingReviews;
final String createdAt;
final String updatedAt;
final Uint8List? icon;
final bool isInstalled;
final bool isEnabled;
final bool isSupported;
final bool isAllowedInPrivateBrowsing;
final bool isAutoUpdateEnabled;
final bool isLocalFileInstalled;
final String? optionsPageUrl;
final bool openOptionsPageInTab;
final AddonDisabledReason? disabledReason;
final AddonIncognito incognito;
const AddonInfo({
required this.id,
required this.displayName,
this.summary,
required this.description,
required this.downloadUrl,
required this.version,
this.installedVersion,
required this.translatedPermissions,
required this.translatedRequiredDataCollectionPermissions,
this.authorName,
this.authorUrl,
required this.homepageUrl,
required this.detailUrl,
required this.ratingUrl,
this.ratingAverage,
this.ratingReviews,
required this.createdAt,
required this.updatedAt,
this.icon,
required this.isInstalled,
required this.isEnabled,
required this.isSupported,
required this.isAllowedInPrivateBrowsing,
required this.isAutoUpdateEnabled,
required this.isLocalFileInstalled,
this.optionsPageUrl,
required this.openOptionsPageInTab,
this.disabledReason,
this.incognito = AddonIncognito.spanning,
});
}
class AddonStoreInfo {
final String latestVersion;
final String latestXpiUrl;
const AddonStoreInfo({
required this.latestVersion,
required this.latestXpiUrl,
});
}
class AddonUpdateAttemptInfo {
final String addonId;
final int dateMillisecondsSinceEpoch;
final AddonUpdateStatus? status;
final String? message;
const AddonUpdateAttemptInfo({
required this.addonId,
required this.dateMillisecondsSinceEpoch,
this.status,
this.message,
});
}
enum GeckoSuggestionType { session, clipboard, history } enum GeckoSuggestionType { session, clipboard, history }
class GeckoSuggestion { class GeckoSuggestion {
@@ -1738,14 +1844,49 @@ abstract class GeckoSelectionActionEvents {
@HostApi() @HostApi()
abstract class GeckoAddonsApi { abstract class GeckoAddonsApi {
void startAddonManagerActivity(); @async
List<AddonInfo> getAddons(bool allowCache);
void startAddonSettingsActivity(String extensionId); @async
AddonInfo? getAddonById(String addonId, bool allowCache);
@async
AddonStoreInfo? getAddonStoreInfo(String addonId);
void invokeAddonAction(String extensionId, WebExtensionActionType actionType); void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
@async
AddonInfo enableAddon(String addonId);
@async
AddonInfo disableAddon(String addonId);
@async
AddonInfo setAddonAllowedInPrivateBrowsing(String addonId, bool allowed);
@async
AddonInfo setAddonAutoUpdateEnabledForAddon(String addonId, bool enabled);
@async
void uninstallAddon(String addonId);
@async
AddonUpdateAttemptInfo? triggerAddonUpdate(String addonId);
@async
void triggerAllAddonUpdates();
@async
AddonUpdateAttemptInfo? getLastAddonUpdateAttempt(String addonId);
@async @async
void installAddon(String url); void installAddon(String url);
@async
bool isAddonAutoUpdateEnabled();
@async
void setAddonAutoUpdateEnabled(bool enabled);
} }
@FlutterApi() @FlutterApi()
@@ -1769,6 +1910,8 @@ abstract class GeckoAddonEvents {
WebExtensionActionType actionType, WebExtensionActionType actionType,
Uint8List icon, Uint8List icon,
); );
void onWebExtensionPopupRequested(String extensionId, String extensionName);
} }
@HostApi() @HostApi()