new extension management
This commit is contained in:
@@ -209,38 +209,6 @@
|
||||
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
|
||||
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
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
|
||||
@@ -34,8 +34,4 @@
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="AddonsActivityTheme" parent="AppTheme">
|
||||
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
|
||||
</style>
|
||||
</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/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/about/presentation/screens/about.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang.dart';
|
||||
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
|
||||
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
|
||||
@@ -97,6 +101,7 @@ import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'
|
||||
part 'routes.bangs.dart';
|
||||
part 'routes.bookmarks.dart';
|
||||
part 'routes.browser.dart';
|
||||
part 'routes.addons.dart';
|
||||
part 'routes.feeds.dart';
|
||||
part 'routes.g.dart';
|
||||
part 'routes.history.dart';
|
||||
|
||||
@@ -13,6 +13,7 @@ List<RouteBase> get $appRoutes => [
|
||||
$bangMenuRoute,
|
||||
$bookmarksRoute,
|
||||
$browserRoute,
|
||||
$addonManagerRoute,
|
||||
$feedListRoute,
|
||||
$historyRoute,
|
||||
$profileListRoute,
|
||||
@@ -931,6 +932,125 @@ extension<T extends Enum> on Map<T, String> {
|
||||
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(
|
||||
path: '/feeds',
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
||||
|
||||
@override
|
||||
FutureOr<bool> build() async {
|
||||
final prefs =
|
||||
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
||||
final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
||||
final pref = prefs[_signatureRequiredPref];
|
||||
final allowUnsigned = pref?.value == false;
|
||||
|
||||
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AddonAutoUpdate extends _$AddonAutoUpdate {
|
||||
Future<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)
|
||||
class BrowserAddonService extends _$BrowserAddonService {
|
||||
Future<Uri> getAddonXpiUrl(String guid) async {
|
||||
|
||||
+44
@@ -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)
|
||||
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
|
||||
|
||||
|
||||
+9
-1
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
||||
.installFromFile(selectedFile.value!);
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Extension installed successfully');
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Extension installed. Automatic updates are disabled for this local version.',
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -149,6 +152,11 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Extensions installed from a local XPI stay pinned to that version and will not update automatically.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (errorMessage.value != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
|
||||
+13
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
|
||||
@@ -305,6 +306,18 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
final addonService = ref.watch(addonServiceProvider);
|
||||
useOnStreamChange(
|
||||
addonService.popupStream,
|
||||
onData: (event) async {
|
||||
await showAddonPopupBottomSheet(
|
||||
context,
|
||||
extensionId: event.extensionId,
|
||||
extensionName: event.extensionName,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
switch (current) {
|
||||
case AppLifecycleState.resumed:
|
||||
|
||||
+121
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -36,6 +36,7 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
WebExtensionActionType.browser,
|
||||
).select((value) => value.values.toList()),
|
||||
);
|
||||
final rootContext = Navigator.of(context, rootNavigator: true).context;
|
||||
Future<void> openExtensionSettings(String extensionId) async {
|
||||
Navigator.pop(context);
|
||||
await addonService.startAddonSettingsActivity(extensionId);
|
||||
await openAddonSettingsFlowById(rootContext, ref, extensionId);
|
||||
}
|
||||
|
||||
return _buildMenuCard(
|
||||
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
icon: MdiIcons.puzzleEdit,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await addonService.startAddonManagerActivity();
|
||||
await const AddonManagerRoute().push<void>(rootContext);
|
||||
},
|
||||
),
|
||||
_buildSubTile(
|
||||
|
||||
+2
@@ -27,6 +27,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
: const AppBarTitle()
|
||||
: null,
|
||||
actions: [
|
||||
const PinnedAddonBar(),
|
||||
if (isSmallWebMode)
|
||||
ReaderButton(
|
||||
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
||||
|
||||
+135
-51
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'Extensions'),
|
||||
_ManageExtensionsTile(),
|
||||
_InstallLocalAddonTile(),
|
||||
_AddonCollectionTile(),
|
||||
SettingSection(name: 'Updates'),
|
||||
_AutoUpdateTile(),
|
||||
SettingSection(name: 'Security'),
|
||||
_AllowUnsignedExtensionsTile(),
|
||||
],
|
||||
@@ -59,6 +62,34 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageExtensionsTile extends StatelessWidget {
|
||||
const _ManageExtensionsTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomListTile(
|
||||
title: 'Manage Extensions',
|
||||
subtitle:
|
||||
'Browse installed, disabled, available, and unsupported extensions',
|
||||
prefix: Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Icon(
|
||||
MdiIcons.puzzleEdit,
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
suffix: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await const AddonManagerRoute().push<void>(context);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallLocalAddonTile extends StatelessWidget {
|
||||
const _InstallLocalAddonTile();
|
||||
|
||||
@@ -113,6 +144,45 @@ class _AddonCollectionTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoUpdateTile extends ConsumerWidget {
|
||||
const _AutoUpdateTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final autoUpdate = ref.watch(addonAutoUpdateProvider);
|
||||
|
||||
return autoUpdate.when(
|
||||
data: (enabled) => SwitchListTile.adaptive(
|
||||
title: const Text('Automatic updates'),
|
||||
subtitle: const Text(
|
||||
'Automatically check for and install extension updates every 12 hours',
|
||||
),
|
||||
secondary: const Icon(Icons.system_update_alt),
|
||||
value: enabled,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(addonAutoUpdateProvider.notifier)
|
||||
.setEnabled(enabled: value);
|
||||
},
|
||||
),
|
||||
loading: () => const SwitchListTile.adaptive(
|
||||
title: Text('Automatic updates'),
|
||||
subtitle: Text(
|
||||
'Automatically check for and install extension updates every 12 hours',
|
||||
),
|
||||
secondary: Icon(Icons.system_update_alt),
|
||||
value: true,
|
||||
onChanged: null,
|
||||
),
|
||||
error: (error, stack) => ListTile(
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: const Text('Automatic updates'),
|
||||
subtitle: Text('Failed to load: $error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
||||
const _AllowUnsignedExtensionsTile();
|
||||
|
||||
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: const Text(
|
||||
'Unsigned extensions have not been verified by Mozilla',
|
||||
),
|
||||
secondary: const Icon(Icons.extension_off),
|
||||
value: allowUnsigned.value ?? false,
|
||||
onChanged: allowUnsigned.isLoading
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
final confirmed =
|
||||
await _showAllowUnsignedConfirmationDialog(context);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
await ref
|
||||
.read(allowUnsignedExtensionsProvider.notifier)
|
||||
.setAllowUnsigned(allow: value);
|
||||
},
|
||||
),
|
||||
if (allowUnsigned.value == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
return allowUnsigned.when(
|
||||
data: (allowed) => Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: const Text(
|
||||
'Unsigned extensions have not been verified by Mozilla',
|
||||
),
|
||||
secondary: const Icon(Icons.extension_off),
|
||||
value: allowed,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final confirmed = await _showAllowUnsignedConfirmationDialog(
|
||||
context,
|
||||
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
await ref
|
||||
.read(allowUnsignedExtensionsProvider.notifier)
|
||||
.setAllowUnsigned(allow: value);
|
||||
},
|
||||
),
|
||||
if (allowed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Only install unsigned extensions from sources you trust. '
|
||||
'They may contain malicious code.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Only install unsigned extensions from sources you trust. '
|
||||
'They may contain malicious code.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
loading: () => const SwitchListTile.adaptive(
|
||||
title: Text('Allow unsigned extensions'),
|
||||
subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
|
||||
secondary: Icon(Icons.extension_off),
|
||||
value: false,
|
||||
onChanged: null,
|
||||
),
|
||||
error: (error, stack) => ListTile(
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: Text('Failed to load: $error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,4 +347,3 @@ class _AllowUnsignedConfirmationDialog extends HookWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -22,7 +22,6 @@ import androidx.annotation.CallSuper
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity
|
||||
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
|
||||
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
|
||||
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||
@@ -555,14 +554,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
protected open fun onEngineSetupComplete() {}
|
||||
|
||||
private fun openPopup(webExtensionState: WebExtensionState) {
|
||||
val intent = Intent(
|
||||
components.profileApplicationContext,
|
||||
WebExtensionActionPopupActivity::class.java
|
||||
)
|
||||
intent.putExtra("web_extension_id", webExtensionState.id)
|
||||
intent.putExtra("web_extension_name", webExtensionState.name)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
components.addonEvents.onWebExtensionPopupRequested(
|
||||
webExtensionState.id,
|
||||
webExtensionState.name ?: "",
|
||||
) {}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
|
||||
+18
-1
@@ -21,6 +21,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
||||
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
|
||||
@@ -268,7 +269,23 @@ object GlobalComponents {
|
||||
},
|
||||
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
|
||||
onExtensionsLoaded = { extensions ->
|
||||
newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
|
||||
val addonPrefs = AddonPrefs.get(applicationContext)
|
||||
val autoUpdateEnabled =
|
||||
addonPrefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
|
||||
val autoUpdateDisabledAddonIds =
|
||||
addonPrefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet())
|
||||
?: emptySet()
|
||||
val localFileAddonIds =
|
||||
addonPrefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet())
|
||||
?: emptySet()
|
||||
if (autoUpdateEnabled) {
|
||||
newComponents.core.addonUpdater.registerForFutureUpdates(
|
||||
extensions.filterNot { extension ->
|
||||
autoUpdateDisabledAddonIds.contains(extension.id) ||
|
||||
localFileAddonIds.contains(extension.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
newComponents.core.supportedAddonsChecker.registerForChecks()
|
||||
},
|
||||
)
|
||||
|
||||
-107
@@ -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)!!)
|
||||
}
|
||||
}
|
||||
-110
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
-36
@@ -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
|
||||
}
|
||||
}
|
||||
-155
@@ -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
|
||||
}
|
||||
+106
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-221
@@ -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()
|
||||
}
|
||||
-106
@@ -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)
|
||||
}
|
||||
}
|
||||
+730
-65
@@ -7,102 +7,767 @@
|
||||
package eu.weblibre.flutter_mozilla_components.api
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity
|
||||
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
|
||||
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
|
||||
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.concept.fetch.MutableHeaders
|
||||
import mozilla.components.concept.fetch.Request
|
||||
import mozilla.components.concept.engine.webextension.InstallationMethod
|
||||
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
|
||||
import mozilla.components.feature.addons.Addon
|
||||
import mozilla.components.feature.addons.update.AddonUpdater
|
||||
import mozilla.components.feature.addons.update.DefaultAddonUpdater
|
||||
import mozilla.components.feature.addons.ui.displayName
|
||||
import mozilla.components.feature.addons.ui.summary
|
||||
import mozilla.components.feature.addons.ui.translateDescription
|
||||
import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
|
||||
import org.json.JSONObject
|
||||
|
||||
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
||||
private val components by lazy {
|
||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||
}
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
override fun startAddonManagerActivity() {
|
||||
val intent = Intent(context, AddonsActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(intent)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val updateAttemptStorage by lazy {
|
||||
DefaultAddonUpdater.UpdateAttemptStorage(context.applicationContext)
|
||||
}
|
||||
private val prefs by lazy {
|
||||
AddonPrefs.get(context.applicationContext)
|
||||
}
|
||||
|
||||
override fun startAddonSettingsActivity(extensionId: String) {
|
||||
companion object {
|
||||
private const val LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE =
|
||||
"No remote update source is available for this locally installed extension."
|
||||
private const val LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE =
|
||||
"Update downloaded and will be applied after restarting the app."
|
||||
private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
|
||||
private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
|
||||
}
|
||||
|
||||
override fun getAddons(allowCache: Boolean, callback: (Result<List<AddonInfo>>) -> Unit) {
|
||||
scope.launch {
|
||||
val addon = runCatching {
|
||||
components.core.addonManager.getAddons()
|
||||
.find { it.id == extensionId }
|
||||
}.getOrNull()
|
||||
|
||||
if (addon == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
startAddonManagerActivity()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val optionsPageUrl = addon.installedState?.optionsPageUrl
|
||||
if (optionsPageUrl.isNullOrEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
startAddonManagerActivity()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (addon.installedState?.openOptionsPageInTab == true) {
|
||||
components.useCases.tabsUseCases.selectOrAddTab(
|
||||
url = optionsPageUrl,
|
||||
ignoreFragment = true,
|
||||
)
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
launchIntent?.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
)
|
||||
if (launchIntent != null) {
|
||||
context.startActivity(launchIntent)
|
||||
} else {
|
||||
startAddonManagerActivity()
|
||||
runCatching {
|
||||
components.core.addonManager.getAddons(allowCache = allowCache)
|
||||
.map { addon ->
|
||||
addon.toPigeon(
|
||||
context = context,
|
||||
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
|
||||
isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val intent = Intent(context, AddonInternalSettingsActivity::class.java)
|
||||
intent.putExtra("add_on", addon)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { callback(Result.success(it)) },
|
||||
onFailure = { callback(Result.failure(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAddonById(
|
||||
addonId: String,
|
||||
allowCache: Boolean,
|
||||
callback: (Result<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) {
|
||||
when(actionType) {
|
||||
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
|
||||
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
when(actionType) {
|
||||
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
|
||||
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableAddon(addonId: String, callback: (Result<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) {
|
||||
val installMethod = if (url.startsWith("file://")) {
|
||||
val isLocalFileInstall = url.startsWith("file://")
|
||||
val installMethod = if (isLocalFileInstall) {
|
||||
InstallationMethod.FROM_FILE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
components.core.addonManager.installAddon(
|
||||
url = url,
|
||||
installationMethod = installMethod,
|
||||
onSuccess = { _ ->
|
||||
callback(Result.success(Unit))
|
||||
},
|
||||
onError = { e ->
|
||||
callback(Result.failure(e))
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
performAddonInstall(url, isLocalFileInstall, callback)
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
callback(Result.failure(throwable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <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
@@ -10,6 +10,8 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import eu.weblibre.flutter_mozilla_components.AddonPopupViewFactory
|
||||
import eu.weblibre.flutter_mozilla_components.AddonSettingsViewFactory
|
||||
import eu.weblibre.flutter_mozilla_components.BrowserFragment
|
||||
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
|
||||
import eu.weblibre.flutter_mozilla_components.EngineProvider
|
||||
@@ -145,6 +147,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
_flutterEvents
|
||||
)
|
||||
)
|
||||
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
|
||||
"eu.weblibre/addon_settings",
|
||||
AddonSettingsViewFactory(activityProvider = { this.activity }),
|
||||
)
|
||||
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
|
||||
"eu.weblibre/addon_popup",
|
||||
AddonPopupViewFactory(activityProvider = { this.activity }),
|
||||
)
|
||||
isPlatformViewRegistered = true
|
||||
|
||||
isGeckoInitialized = false
|
||||
|
||||
+791
-217
File diff suppressed because it is too large
Load Diff
-157
@@ -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>
|
||||
-15
@@ -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>
|
||||
-32
@@ -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>
|
||||
-94
@@ -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>
|
||||
-30
@@ -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>
|
||||
</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
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
|
||||
@@ -38,6 +38,12 @@ export 'src/pigeons/gecko.g.dart'
|
||||
show
|
||||
AddTabParams,
|
||||
AddonCollection,
|
||||
AddonDisabledReason,
|
||||
AddonIncognito,
|
||||
AddonInfo,
|
||||
AddonStoreInfo,
|
||||
AddonUpdateAttemptInfo,
|
||||
AddonUpdateStatus,
|
||||
AppLinksMode,
|
||||
AudioHitResult,
|
||||
AutoplayStatus,
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:rxdart/rxdart.dart';
|
||||
|
||||
typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data});
|
||||
typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes});
|
||||
typedef ExtensionPopupEvent = ({String extensionId, String extensionName});
|
||||
|
||||
final _apiInstance = GeckoAddonsApi();
|
||||
|
||||
@@ -24,6 +25,7 @@ class GeckoAddonService extends GeckoAddonEvents {
|
||||
|
||||
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
|
||||
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
|
||||
final _popupSubject = PublishSubject<ExtensionPopupEvent>();
|
||||
|
||||
Stream<ExtensionDataEvent> get browserExtensionStream =>
|
||||
_browserExtensionSubject.stream;
|
||||
@@ -33,13 +35,18 @@ class GeckoAddonService extends GeckoAddonEvents {
|
||||
Stream<ExtensionIconEvent> get browserIconStream =>
|
||||
_browserIconSubject.stream;
|
||||
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
|
||||
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
|
||||
|
||||
Future<void> startAddonManagerActivity() {
|
||||
return _api.startAddonManagerActivity();
|
||||
Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
|
||||
return _api.getAddons(allowCache);
|
||||
}
|
||||
|
||||
Future<void> startAddonSettingsActivity(String extensionId) {
|
||||
return _api.startAddonSettingsActivity(extensionId);
|
||||
Future<AddonInfo?> getAddonById(String addonId, {bool allowCache = true}) {
|
||||
return _api.getAddonById(addonId, allowCache);
|
||||
}
|
||||
|
||||
Future<AddonStoreInfo?> getAddonStoreInfo(String addonId) {
|
||||
return _api.getAddonStoreInfo(addonId);
|
||||
}
|
||||
|
||||
Future<void> invokeAddonAction(
|
||||
@@ -53,6 +60,52 @@ class GeckoAddonService extends GeckoAddonEvents {
|
||||
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
|
||||
void onRemoveWebExtensionAction(
|
||||
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({
|
||||
BinaryMessenger? binaryMessenger,
|
||||
GeckoAddonsApi? api,
|
||||
@@ -132,5 +190,6 @@ class GeckoAddonService extends GeckoAddonEvents {
|
||||
await _pageExtensionSubject.close();
|
||||
await _browserIconSubject.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 }
|
||||
|
||||
class GeckoSuggestion {
|
||||
@@ -1738,14 +1844,49 @@ abstract class GeckoSelectionActionEvents {
|
||||
|
||||
@HostApi()
|
||||
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);
|
||||
|
||||
@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
|
||||
void installAddon(String url);
|
||||
|
||||
@async
|
||||
bool isAddonAutoUpdateEnabled();
|
||||
|
||||
@async
|
||||
void setAddonAutoUpdateEnabled(bool enabled);
|
||||
}
|
||||
|
||||
@FlutterApi()
|
||||
@@ -1769,6 +1910,8 @@ abstract class GeckoAddonEvents {
|
||||
WebExtensionActionType actionType,
|
||||
Uint8List icon,
|
||||
);
|
||||
|
||||
void onWebExtensionPopupRequested(String extensionId, String extensionName);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
|
||||
Reference in New Issue
Block a user