new extension management
This commit is contained in:
@@ -209,38 +209,6 @@
|
|||||||
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
|
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:theme="@style/AddonsActivityTheme"
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:parentActivityName=".MainActivity" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:theme="@style/AppTheme"
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:label="@string/mozac_feature_addons_addons" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
android:label="@string/mozac_feature_addons_addons"
|
||||||
|
|||||||
@@ -34,8 +34,4 @@
|
|||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AddonsActivityTheme" parent="AppTheme">
|
|
||||||
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
part of 'routes.dart';
|
||||||
|
|
||||||
|
@TypedGoRoute<AddonManagerRoute>(
|
||||||
|
name: 'AddonManagerRoute',
|
||||||
|
path: '/addons',
|
||||||
|
routes: [
|
||||||
|
TypedGoRoute<AddonDetailsRoute>(
|
||||||
|
name: 'AddonDetailsRoute',
|
||||||
|
path: 'details/:addonId',
|
||||||
|
),
|
||||||
|
TypedGoRoute<AddonPermissionsRoute>(
|
||||||
|
name: 'AddonPermissionsRoute',
|
||||||
|
path: 'permissions/:addonId',
|
||||||
|
),
|
||||||
|
TypedGoRoute<AddonInternalSettingsRoute>(
|
||||||
|
name: 'AddonInternalSettingsRoute',
|
||||||
|
path: 'settings/:addonId',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class AddonManagerRoute extends GoRouteData with $AddonManagerRoute {
|
||||||
|
const AddonManagerRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const AddonManagerScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonDetailsRoute extends GoRouteData with $AddonDetailsRoute {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonDetailsRoute({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return AddonDetailsScreen(addonId: addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonPermissionsRoute({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return AddonPermissionsScreen(addonId: addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonInternalSettingsRoute extends GoRouteData
|
||||||
|
with $AddonInternalSettingsRoute {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonInternalSettingsRoute({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return AddonInternalSettingsScreen(addonId: addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart';
|
|||||||
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
|
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
|
||||||
import 'package:weblibre/domain/entities/profile.dart';
|
import 'package:weblibre/domain/entities/profile.dart';
|
||||||
import 'package:weblibre/features/about/presentation/screens/about.dart';
|
import 'package:weblibre/features/about/presentation/screens/about.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang.dart';
|
import 'package:weblibre/features/bangs/data/models/bang.dart';
|
||||||
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
|
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
|
||||||
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
|
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
|
||||||
@@ -97,6 +101,7 @@ import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'
|
|||||||
part 'routes.bangs.dart';
|
part 'routes.bangs.dart';
|
||||||
part 'routes.bookmarks.dart';
|
part 'routes.bookmarks.dart';
|
||||||
part 'routes.browser.dart';
|
part 'routes.browser.dart';
|
||||||
|
part 'routes.addons.dart';
|
||||||
part 'routes.feeds.dart';
|
part 'routes.feeds.dart';
|
||||||
part 'routes.g.dart';
|
part 'routes.g.dart';
|
||||||
part 'routes.history.dart';
|
part 'routes.history.dart';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ List<RouteBase> get $appRoutes => [
|
|||||||
$bangMenuRoute,
|
$bangMenuRoute,
|
||||||
$bookmarksRoute,
|
$bookmarksRoute,
|
||||||
$browserRoute,
|
$browserRoute,
|
||||||
|
$addonManagerRoute,
|
||||||
$feedListRoute,
|
$feedListRoute,
|
||||||
$historyRoute,
|
$historyRoute,
|
||||||
$profileListRoute,
|
$profileListRoute,
|
||||||
@@ -931,6 +932,125 @@ extension<T extends Enum> on Map<T, String> {
|
|||||||
entries.where((element) => element.value == value).firstOrNull?.key;
|
entries.where((element) => element.value == value).firstOrNull?.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RouteBase get $addonManagerRoute => GoRouteData.$route(
|
||||||
|
path: '/addons',
|
||||||
|
name: 'AddonManagerRoute',
|
||||||
|
factory: $AddonManagerRoute._fromState,
|
||||||
|
routes: [
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'details/:addonId',
|
||||||
|
name: 'AddonDetailsRoute',
|
||||||
|
factory: $AddonDetailsRoute._fromState,
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'permissions/:addonId',
|
||||||
|
name: 'AddonPermissionsRoute',
|
||||||
|
factory: $AddonPermissionsRoute._fromState,
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'settings/:addonId',
|
||||||
|
name: 'AddonInternalSettingsRoute',
|
||||||
|
factory: $AddonInternalSettingsRoute._fromState,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
mixin $AddonManagerRoute on GoRouteData {
|
||||||
|
static AddonManagerRoute _fromState(GoRouterState state) =>
|
||||||
|
const AddonManagerRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location('/addons');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $AddonDetailsRoute on GoRouteData {
|
||||||
|
static AddonDetailsRoute _fromState(GoRouterState state) =>
|
||||||
|
AddonDetailsRoute(addonId: state.pathParameters['addonId']!);
|
||||||
|
|
||||||
|
AddonDetailsRoute get _self => this as AddonDetailsRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location(
|
||||||
|
'/addons/details/${Uri.encodeComponent(_self.addonId)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $AddonPermissionsRoute on GoRouteData {
|
||||||
|
static AddonPermissionsRoute _fromState(GoRouterState state) =>
|
||||||
|
AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
|
||||||
|
|
||||||
|
AddonPermissionsRoute get _self => this as AddonPermissionsRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location(
|
||||||
|
'/addons/permissions/${Uri.encodeComponent(_self.addonId)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $AddonInternalSettingsRoute on GoRouteData {
|
||||||
|
static AddonInternalSettingsRoute _fromState(GoRouterState state) =>
|
||||||
|
AddonInternalSettingsRoute(addonId: state.pathParameters['addonId']!);
|
||||||
|
|
||||||
|
AddonInternalSettingsRoute get _self => this as AddonInternalSettingsRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location(
|
||||||
|
'/addons/settings/${Uri.encodeComponent(_self.addonId)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
RouteBase get $feedListRoute => GoRouteData.$route(
|
RouteBase get $feedListRoute => GoRouteData.$route(
|
||||||
path: '/feeds',
|
path: '/feeds',
|
||||||
name: 'FeedListRoute',
|
name: 'FeedListRoute',
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:riverpod/experimental/persist.dart';
|
||||||
|
import 'package:riverpod_annotation/experimental/persist.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/user/data/providers.dart';
|
||||||
|
|
||||||
|
part 'providers.g.dart';
|
||||||
|
|
||||||
|
sealed class AddonUpdateOutcome {
|
||||||
|
const AddonUpdateOutcome();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateOutcomeAvailable extends AddonUpdateOutcome {
|
||||||
|
final AddonInfo addon;
|
||||||
|
final String availableVersion;
|
||||||
|
|
||||||
|
const AddonUpdateOutcomeAvailable({
|
||||||
|
required this.addon,
|
||||||
|
required this.availableVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateOutcomeUpToDate extends AddonUpdateOutcome {
|
||||||
|
const AddonUpdateOutcomeUpToDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateOutcomeMissing extends AddonUpdateOutcome {
|
||||||
|
const AddonUpdateOutcomeMissing();
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AddonUpdateRunResult {
|
||||||
|
const AddonUpdateRunResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateRunDone extends AddonUpdateRunResult {
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const AddonUpdateRunDone(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateRunNoRemoteSource extends AddonUpdateRunResult {
|
||||||
|
const AddonUpdateRunNoRemoteSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateRunFailed extends AddonUpdateRunResult {
|
||||||
|
const AddonUpdateRunFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolveAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
|
||||||
|
final latest = storeInfo?.latestVersion.trim();
|
||||||
|
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class AddonDetails extends _$AddonDetails {
|
||||||
|
GeckoAddonService get _service => ref.read(addonServiceProvider);
|
||||||
|
|
||||||
|
Future<void> _run(Future<AddonInfo?> Function() action) async {
|
||||||
|
state = const AsyncLoading<AddonInfo?>();
|
||||||
|
state = await AsyncValue.guard(action);
|
||||||
|
|
||||||
|
ref.invalidate(addonListProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> install() async {
|
||||||
|
final current = state.value;
|
||||||
|
if (current == null) return;
|
||||||
|
|
||||||
|
await _run(() async {
|
||||||
|
await _service.installAddon(Uri.parse(current.downloadUrl));
|
||||||
|
return _service.getAddonById(addonId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uninstall() async {
|
||||||
|
await _run(() async {
|
||||||
|
await _service.uninstallAddon(addonId);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setEnabled({required bool enabled}) async {
|
||||||
|
await _run(
|
||||||
|
() => enabled
|
||||||
|
? _service.enableAddon(addonId)
|
||||||
|
: _service.disableAddon(addonId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAllowedInPrivateBrowsing({required bool allowed}) async {
|
||||||
|
await _run(
|
||||||
|
() => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAutoUpdateEnabled({required bool enabled}) async {
|
||||||
|
await _run(
|
||||||
|
() => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AddonInfo?> build(String addonId) {
|
||||||
|
return _service.getAddonById(addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String addonId) {
|
||||||
|
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<AddonUpdateAttemptInfo?> lastAddonUpdateAttempt(
|
||||||
|
Ref ref,
|
||||||
|
String addonId,
|
||||||
|
) {
|
||||||
|
return ref.read(addonServiceProvider).getLastAddonUpdateAttempt(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class AddonUpdateCheck extends _$AddonUpdateCheck {
|
||||||
|
/// Refreshes store info and returns whether an update is available.
|
||||||
|
Future<AddonUpdateOutcome> resolveAvailableUpdate() async {
|
||||||
|
final storeInfo = await ref
|
||||||
|
.read(addonServiceProvider)
|
||||||
|
.getAddonStoreInfo(addonId);
|
||||||
|
final fresh = await ref
|
||||||
|
.read(addonServiceProvider)
|
||||||
|
.getAddonById(addonId, allowCache: false);
|
||||||
|
|
||||||
|
if (fresh == null) return const AddonUpdateOutcomeMissing();
|
||||||
|
|
||||||
|
final available = _resolveAvailableVersion(fresh, storeInfo);
|
||||||
|
final hasUpdate =
|
||||||
|
fresh.installedVersion != null &&
|
||||||
|
available.isNotEmpty &&
|
||||||
|
fresh.installedVersion != available;
|
||||||
|
|
||||||
|
return hasUpdate
|
||||||
|
? AddonUpdateOutcomeAvailable(addon: fresh, availableVersion: available)
|
||||||
|
: const AddonUpdateOutcomeUpToDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers a remote update and awaits completion. Invalidates dependent
|
||||||
|
/// providers on completion.
|
||||||
|
Future<AddonUpdateRunResult> triggerAndAwait() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
|
||||||
|
final result = await AsyncValue.guard<AddonUpdateRunResult>(() async {
|
||||||
|
final AddonUpdateAttemptInfo? attempt;
|
||||||
|
try {
|
||||||
|
attempt = await ref
|
||||||
|
.read(addonServiceProvider)
|
||||||
|
.triggerAddonUpdate(addonId);
|
||||||
|
} catch (error) {
|
||||||
|
final noRemote = error.toString().contains(
|
||||||
|
'No remote update source is available for this locally installed extension.',
|
||||||
|
);
|
||||||
|
return noRemote
|
||||||
|
? const AddonUpdateRunNoRemoteSource()
|
||||||
|
: const AddonUpdateRunFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempt?.status == AddonUpdateStatus.error
|
||||||
|
? const AddonUpdateRunFailed()
|
||||||
|
: AddonUpdateRunDone(attempt?.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
state = result;
|
||||||
|
ref.invalidate(addonDetailsProvider(addonId));
|
||||||
|
ref.invalidate(lastAddonUpdateAttemptProvider(addonId));
|
||||||
|
|
||||||
|
return result.value ?? const AddonUpdateRunFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AsyncValue<AddonUpdateRunResult> build(String addonId) =>
|
||||||
|
const AsyncData(AddonUpdateRunDone(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class AddonList extends _$AddonList {
|
||||||
|
GeckoAddonService get _service => ref.read(addonServiceProvider);
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> install(AddonInfo addon) async {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
|
||||||
|
try {
|
||||||
|
await _service.installAddon(Uri.parse(addon.downloadUrl));
|
||||||
|
ref.invalidate(addonDetailsProvider(addon.id));
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uninstall(AddonInfo addon) async {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
|
||||||
|
try {
|
||||||
|
await _service.uninstallAddon(addon.id);
|
||||||
|
ref.invalidate(addonDetailsProvider(addon.id));
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AddonInfo>> build() {
|
||||||
|
return _service.getAddons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class AddonBusyIds extends _$AddonBusyIds {
|
||||||
|
void add(String id) => state = {...state, id};
|
||||||
|
void remove(String id) => state = {...state}..remove(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<String> build() => const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class PinnedAddonIds extends _$PinnedAddonIds {
|
||||||
|
void setPinned(String addonId, {required bool pinned}) {
|
||||||
|
if (pinned) {
|
||||||
|
if (!state.contains(addonId)) {
|
||||||
|
state = {...state, addonId};
|
||||||
|
}
|
||||||
|
} else if (state.contains(addonId)) {
|
||||||
|
state = {...state}..remove(addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<String> build() {
|
||||||
|
persist(
|
||||||
|
ref.watch(riverpodDatabaseStorageProvider),
|
||||||
|
key: 'PinnedAddonIds',
|
||||||
|
encode: (state) => jsonEncode(state.toList()),
|
||||||
|
decode: (encoded) =>
|
||||||
|
(jsonDecode(encoded) as List<dynamic>).cast<String>().toSet(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return stateOrNull ?? const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class BulkAddonUpdate extends _$BulkAddonUpdate {
|
||||||
|
Future<void> triggerAll() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
await ref.read(addonServiceProvider).triggerAllAddonUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'providers.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(AddonDetails)
|
||||||
|
final addonDetailsProvider = AddonDetailsFamily._();
|
||||||
|
|
||||||
|
final class AddonDetailsProvider
|
||||||
|
extends $AsyncNotifierProvider<AddonDetails, AddonInfo?> {
|
||||||
|
AddonDetailsProvider._({
|
||||||
|
required AddonDetailsFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonDetailsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonDetailsHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'addonDetailsProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AddonDetails create() => AddonDetails();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is AddonDetailsProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonDetailsHash() => r'26b4a33e9d17aced1d3fb5c6ff28921f611ca5b0';
|
||||||
|
|
||||||
|
final class AddonDetailsFamily extends $Family
|
||||||
|
with
|
||||||
|
$ClassFamilyOverride<
|
||||||
|
AddonDetails,
|
||||||
|
AsyncValue<AddonInfo?>,
|
||||||
|
AddonInfo?,
|
||||||
|
FutureOr<AddonInfo?>,
|
||||||
|
String
|
||||||
|
> {
|
||||||
|
AddonDetailsFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonDetailsProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonDetailsProvider call(String addonId) =>
|
||||||
|
AddonDetailsProvider._(argument: addonId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'addonDetailsProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$AddonDetails extends $AsyncNotifier<AddonInfo?> {
|
||||||
|
late final _$args = ref.$arg as String;
|
||||||
|
String get addonId => _$args;
|
||||||
|
|
||||||
|
FutureOr<AddonInfo?> build(String addonId);
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<AddonInfo?>, AddonInfo?>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<AddonInfo?>, AddonInfo?>,
|
||||||
|
AsyncValue<AddonInfo?>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, () => build(_$args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(addonStoreInfo)
|
||||||
|
final addonStoreInfoProvider = AddonStoreInfoFamily._();
|
||||||
|
|
||||||
|
final class AddonStoreInfoProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<AddonStoreInfo?>,
|
||||||
|
AddonStoreInfo?,
|
||||||
|
FutureOr<AddonStoreInfo?>
|
||||||
|
>
|
||||||
|
with $FutureModifier<AddonStoreInfo?>, $FutureProvider<AddonStoreInfo?> {
|
||||||
|
AddonStoreInfoProvider._({
|
||||||
|
required AddonStoreInfoFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonStoreInfoProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonStoreInfoHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'addonStoreInfoProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<AddonStoreInfo?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AddonStoreInfo?> create(Ref ref) {
|
||||||
|
final argument = this.argument as String;
|
||||||
|
return addonStoreInfo(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is AddonStoreInfoProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonStoreInfoHash() => r'0024a540ecf6f1d55243d9dc963e04fbc212275e';
|
||||||
|
|
||||||
|
final class AddonStoreInfoFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<AddonStoreInfo?>, String> {
|
||||||
|
AddonStoreInfoFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonStoreInfoProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonStoreInfoProvider call(String addonId) =>
|
||||||
|
AddonStoreInfoProvider._(argument: addonId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'addonStoreInfoProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(lastAddonUpdateAttempt)
|
||||||
|
final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
|
||||||
|
|
||||||
|
final class LastAddonUpdateAttemptProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<AddonUpdateAttemptInfo?>,
|
||||||
|
AddonUpdateAttemptInfo?,
|
||||||
|
FutureOr<AddonUpdateAttemptInfo?>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<AddonUpdateAttemptInfo?>,
|
||||||
|
$FutureProvider<AddonUpdateAttemptInfo?> {
|
||||||
|
LastAddonUpdateAttemptProvider._({
|
||||||
|
required LastAddonUpdateAttemptFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'lastAddonUpdateAttemptProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$lastAddonUpdateAttemptHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'lastAddonUpdateAttemptProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<AddonUpdateAttemptInfo?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AddonUpdateAttemptInfo?> create(Ref ref) {
|
||||||
|
final argument = this.argument as String;
|
||||||
|
return lastAddonUpdateAttempt(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is LastAddonUpdateAttemptProvider &&
|
||||||
|
other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$lastAddonUpdateAttemptHash() =>
|
||||||
|
r'79847d9f5720fea1f742d57c3b17272ffd980cc1';
|
||||||
|
|
||||||
|
final class LastAddonUpdateAttemptFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<AddonUpdateAttemptInfo?>, String> {
|
||||||
|
LastAddonUpdateAttemptFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'lastAddonUpdateAttemptProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
LastAddonUpdateAttemptProvider call(String addonId) =>
|
||||||
|
LastAddonUpdateAttemptProvider._(argument: addonId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'lastAddonUpdateAttemptProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(AddonUpdateCheck)
|
||||||
|
final addonUpdateCheckProvider = AddonUpdateCheckFamily._();
|
||||||
|
|
||||||
|
final class AddonUpdateCheckProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<AddonUpdateCheck, AsyncValue<AddonUpdateRunResult>> {
|
||||||
|
AddonUpdateCheckProvider._({
|
||||||
|
required AddonUpdateCheckFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonUpdateCheckProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonUpdateCheckHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'addonUpdateCheckProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AddonUpdateCheck create() => AddonUpdateCheck();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AsyncValue<AddonUpdateRunResult> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AsyncValue<AddonUpdateRunResult>>(
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is AddonUpdateCheckProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonUpdateCheckHash() => r'4ef375b5cd9b0eb89fbbdaff3f93a35482191af5';
|
||||||
|
|
||||||
|
final class AddonUpdateCheckFamily extends $Family
|
||||||
|
with
|
||||||
|
$ClassFamilyOverride<
|
||||||
|
AddonUpdateCheck,
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
String
|
||||||
|
> {
|
||||||
|
AddonUpdateCheckFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonUpdateCheckProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonUpdateCheckProvider call(String addonId) =>
|
||||||
|
AddonUpdateCheckProvider._(argument: addonId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'addonUpdateCheckProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$AddonUpdateCheck
|
||||||
|
extends $Notifier<AsyncValue<AddonUpdateRunResult>> {
|
||||||
|
late final _$args = ref.$arg as String;
|
||||||
|
String get addonId => _$args;
|
||||||
|
|
||||||
|
AsyncValue<AddonUpdateRunResult> build(String addonId);
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
AsyncValue<AddonUpdateRunResult>
|
||||||
|
>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
AsyncValue<AddonUpdateRunResult>
|
||||||
|
>,
|
||||||
|
AsyncValue<AddonUpdateRunResult>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, () => build(_$args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(AddonList)
|
||||||
|
final addonListProvider = AddonListProvider._();
|
||||||
|
|
||||||
|
final class AddonListProvider
|
||||||
|
extends $AsyncNotifierProvider<AddonList, List<AddonInfo>> {
|
||||||
|
AddonListProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'addonListProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonListHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AddonList create() => AddonList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonListHash() => r'7625b69a433d073e186571453e39b557d8b48a5d';
|
||||||
|
|
||||||
|
abstract class _$AddonList extends $AsyncNotifier<List<AddonInfo>> {
|
||||||
|
FutureOr<List<AddonInfo>> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<List<AddonInfo>>, List<AddonInfo>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<List<AddonInfo>>, List<AddonInfo>>,
|
||||||
|
AsyncValue<List<AddonInfo>>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(AddonBusyIds)
|
||||||
|
final addonBusyIdsProvider = AddonBusyIdsProvider._();
|
||||||
|
|
||||||
|
final class AddonBusyIdsProvider
|
||||||
|
extends $NotifierProvider<AddonBusyIds, Set<String>> {
|
||||||
|
AddonBusyIdsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'addonBusyIdsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonBusyIdsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AddonBusyIds create() => AddonBusyIds();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Set<String> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Set<String>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonBusyIdsHash() => r'6f9761320d42b2b5132936797617fdb13b718dd3';
|
||||||
|
|
||||||
|
abstract class _$AddonBusyIds extends $Notifier<Set<String>> {
|
||||||
|
Set<String> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<Set<String>, Set<String>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<Set<String>, Set<String>>,
|
||||||
|
Set<String>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(PinnedAddonIds)
|
||||||
|
final pinnedAddonIdsProvider = PinnedAddonIdsProvider._();
|
||||||
|
|
||||||
|
final class PinnedAddonIdsProvider
|
||||||
|
extends $NotifierProvider<PinnedAddonIds, Set<String>> {
|
||||||
|
PinnedAddonIdsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'pinnedAddonIdsProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$pinnedAddonIdsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
PinnedAddonIds create() => PinnedAddonIds();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Set<String> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Set<String>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$pinnedAddonIdsHash() => r'4f46cd69d4817e6e19e4d04f4fdcf83d5e2efb8c';
|
||||||
|
|
||||||
|
abstract class _$PinnedAddonIds extends $Notifier<Set<String>> {
|
||||||
|
Set<String> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<Set<String>, Set<String>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<Set<String>, Set<String>>,
|
||||||
|
Set<String>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(BulkAddonUpdate)
|
||||||
|
final bulkAddonUpdateProvider = BulkAddonUpdateProvider._();
|
||||||
|
|
||||||
|
final class BulkAddonUpdateProvider
|
||||||
|
extends $NotifierProvider<BulkAddonUpdate, AsyncValue<void>> {
|
||||||
|
BulkAddonUpdateProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'bulkAddonUpdateProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$bulkAddonUpdateHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
BulkAddonUpdate create() => BulkAddonUpdate();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AsyncValue<void> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$bulkAddonUpdateHash() => r'2605711734f9eb6af70e721a4337556a7ea85512';
|
||||||
|
|
||||||
|
abstract class _$BulkAddonUpdate extends $Notifier<AsyncValue<void>> {
|
||||||
|
AsyncValue<void> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
|
||||||
|
AsyncValue<void>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
|
||||||
|
extension AddonInfoUi on AddonInfo {
|
||||||
|
bool get hasOptionsPage => optionsPageUrl?.isNotEmpty ?? false;
|
||||||
|
|
||||||
|
bool get canUserToggleEnabled {
|
||||||
|
return switch (disabledReason) {
|
||||||
|
AddonDisabledReason.blocklisted ||
|
||||||
|
AddonDisabledReason.notCorrectlySigned ||
|
||||||
|
AddonDisabledReason.incompatible => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get statusBannerMessage {
|
||||||
|
return switch (disabledReason) {
|
||||||
|
AddonDisabledReason.blocklisted =>
|
||||||
|
'This extension has been blocklisted and should remain disabled.',
|
||||||
|
AddonDisabledReason.notCorrectlySigned =>
|
||||||
|
'This extension is not correctly signed and cannot be safely enabled.',
|
||||||
|
AddonDisabledReason.incompatible =>
|
||||||
|
'This extension is incompatible with the current app version.',
|
||||||
|
AddonDisabledReason.softBlocked =>
|
||||||
|
isEnabled
|
||||||
|
? 'This extension is soft-blocked. Use caution while it remains enabled.'
|
||||||
|
: 'This extension is soft-blocked, but it can still be re-enabled.',
|
||||||
|
AddonDisabledReason.unsupported =>
|
||||||
|
'This extension is installed, but WebLibre does not currently support it.',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class AddonDetailsScreen extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonDetailsScreen({required this.addonId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||||
|
final addon = addonAsync.value;
|
||||||
|
|
||||||
|
if (addonAsync.isLoading && addon == null) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addon == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Extension')),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
addonAsync.error?.toString() ??
|
||||||
|
'This extension could not be found.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(addon.displayName),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: addonAsync.isLoading
|
||||||
|
? null
|
||||||
|
: ref.read(addonDetailsProvider(addonId).notifier).refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: ref.read(addonDetailsProvider(addonId).notifier).refresh,
|
||||||
|
child: _AddonDetailsBody(addonId: addonId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonDetailsBody extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const _AddonDetailsBody({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final addon = ref.watch(
|
||||||
|
addonDetailsProvider(addonId).select((value) => value.value),
|
||||||
|
);
|
||||||
|
if (addon == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_AddonHeader(addon: addon),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (addon.isInstalled) ...[
|
||||||
|
_ManagementSection(addonId: addonId),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_UpdatesSection(addonId: addonId),
|
||||||
|
] else ...[
|
||||||
|
_InstallButton(addonId: addonId),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
AddonPermissionsRoute(addonId: addon.id).push<void>(context),
|
||||||
|
icon: const Icon(Icons.privacy_tip_outlined),
|
||||||
|
label: const Text('View Permissions'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Details', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_DetailsCard(addon: addon),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Description', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_DescriptionCard(addon: addon),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InstallButton extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const _InstallButton({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||||
|
final addon = addonAsync.value;
|
||||||
|
|
||||||
|
return FilledButton.icon(
|
||||||
|
onPressed: (addonAsync.isLoading || addon == null)
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final displayName = addon.displayName;
|
||||||
|
|
||||||
|
await ref.read(addonDetailsProvider(addonId).notifier).install();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showInfoMessage(context, '$displayName installed');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Install Extension'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonHeader extends StatelessWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const _AddonHeader({required this.addon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AddonIconView(addon: addon, size: 56),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
addon.displayName,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
if ((addon.summary ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(addon.summary!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
label: Text(
|
||||||
|
addon.isInstalled
|
||||||
|
? (addon.isEnabled ? 'Installed' : 'Disabled')
|
||||||
|
: 'Available',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (addon.isAllowedInPrivateBrowsing)
|
||||||
|
const Chip(label: Text('Private Browsing')),
|
||||||
|
if (addon.ratingAverage != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.star, size: 18),
|
||||||
|
label: Text(
|
||||||
|
'${addon.ratingAverage!.toStringAsFixed(1)}'
|
||||||
|
' (${addon.ratingReviews ?? 0})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
AddonStatusBanner(addon: addon),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManagementSection extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const _ManagementSection({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||||
|
final addon = addonAsync.value;
|
||||||
|
|
||||||
|
if (addon == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final globalAutoUpdate = ref.watch(addonAutoUpdateProvider);
|
||||||
|
final isLocalFileInstalled = addon.isLocalFileInstalled;
|
||||||
|
|
||||||
|
final isPinned = ref.watch(pinnedAddonIdsProvider).contains(addonId);
|
||||||
|
|
||||||
|
final (
|
||||||
|
globalAutoUpdateEnabled,
|
||||||
|
canChangePerAddonAutoUpdate,
|
||||||
|
) = globalAutoUpdate.when(
|
||||||
|
data: (enabled) =>
|
||||||
|
(enabled, !addonAsync.isLoading && enabled && !isLocalFileInstalled),
|
||||||
|
loading: () => (true, false),
|
||||||
|
error: (_, _) => (true, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
final autoUpdateSubtitle = switch ((
|
||||||
|
isLocalFileInstalled,
|
||||||
|
addon.isAutoUpdateEnabled,
|
||||||
|
globalAutoUpdateEnabled,
|
||||||
|
)) {
|
||||||
|
(_, _, false) => 'Global automatic updates are disabled.',
|
||||||
|
(true, _, true) =>
|
||||||
|
'Run a manual update once and restart the app before automatic updates can be enabled.',
|
||||||
|
(false, true, true) =>
|
||||||
|
'Allow this extension to receive background updates.',
|
||||||
|
(false, false, true) =>
|
||||||
|
'Background updates are disabled for this extension.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Management', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (addon.isSupported)
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
title: const Text('Enabled'),
|
||||||
|
subtitle: Text(
|
||||||
|
addon.canUserToggleEnabled
|
||||||
|
? 'Allow this extension to run in WebLibre.'
|
||||||
|
: 'This extension cannot be safely enabled.',
|
||||||
|
),
|
||||||
|
value: addon.isEnabled,
|
||||||
|
onChanged: addonAsync.isLoading || !addon.canUserToggleEnabled
|
||||||
|
? null
|
||||||
|
: (enabled) => ref
|
||||||
|
.read(addonDetailsProvider(addonId).notifier)
|
||||||
|
.setEnabled(enabled: enabled),
|
||||||
|
),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
title: const Text('Allow in Private Browsing'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Let this extension run in private browsing tabs.',
|
||||||
|
),
|
||||||
|
value: addon.isAllowedInPrivateBrowsing,
|
||||||
|
onChanged: addonAsync.isLoading
|
||||||
|
? null
|
||||||
|
: (allowed) => ref
|
||||||
|
.read(addonDetailsProvider(addonId).notifier)
|
||||||
|
.setAllowedInPrivateBrowsing(allowed: allowed),
|
||||||
|
),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
title: const Text('Automatic updates'),
|
||||||
|
subtitle: Text(autoUpdateSubtitle),
|
||||||
|
value: addon.isAutoUpdateEnabled,
|
||||||
|
onChanged: canChangePerAddonAutoUpdate
|
||||||
|
? (enabled) => ref
|
||||||
|
.read(addonDetailsProvider(addonId).notifier)
|
||||||
|
.setAutoUpdateEnabled(enabled: enabled)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
title: const Text('Pin to toolbar'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show this extension as an icon in the main tab bar.',
|
||||||
|
),
|
||||||
|
value: isPinned,
|
||||||
|
onChanged: (pinned) {
|
||||||
|
ref
|
||||||
|
.read(pinnedAddonIdsProvider.notifier)
|
||||||
|
.setPinned(addonId, pinned: pinned);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (addon.hasOptionsPage)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings_outlined),
|
||||||
|
title: const Text('Extension Settings'),
|
||||||
|
subtitle: Text(
|
||||||
|
addon.openOptionsPageInTab
|
||||||
|
? 'Open the extension options page in a browser tab'
|
||||||
|
: 'Open the extension options page',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => openAddonSettingsFlow(context, ref, addon),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.privacy_tip_outlined),
|
||||||
|
title: const Text('Permissions'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => AddonPermissionsRoute(
|
||||||
|
addonId: addon.id,
|
||||||
|
).push<void>(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline),
|
||||||
|
title: const Text('Remove Extension'),
|
||||||
|
textColor: theme.colorScheme.error,
|
||||||
|
iconColor: theme.colorScheme.error,
|
||||||
|
onTap: addonAsync.isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final confirmed = await _showConfirmUninstallDialog(
|
||||||
|
context,
|
||||||
|
addon,
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final displayName = addon.displayName;
|
||||||
|
await ref
|
||||||
|
.read(addonDetailsProvider(addonId).notifier)
|
||||||
|
.uninstall();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showInfoMessage(context, '$displayName removed');
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _showConfirmUninstallDialog(
|
||||||
|
BuildContext context,
|
||||||
|
AddonInfo addon,
|
||||||
|
) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Remove extension?'),
|
||||||
|
content: Text('Remove ${addon.displayName} from WebLibre?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdatesSection extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const _UpdatesSection({required this.addonId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final addon = ref.watch(addonDetailsProvider(addonId)).value;
|
||||||
|
if (addon == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final storeInfo = ref.watch(addonStoreInfoProvider(addonId)).value;
|
||||||
|
final updateAttempt = ref
|
||||||
|
.watch(lastAddonUpdateAttemptProvider(addonId))
|
||||||
|
.value;
|
||||||
|
final checking = ref.watch(addonUpdateCheckProvider(addonId)).isLoading;
|
||||||
|
|
||||||
|
final availableVersion = _displayAvailableVersion(addon, storeInfo);
|
||||||
|
final hasAvailableUpdate =
|
||||||
|
addon.installedVersion != null &&
|
||||||
|
availableVersion.isNotEmpty &&
|
||||||
|
addon.installedVersion != availableVersion;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Updates', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(formatUpdateAttemptStatus(updateAttempt)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (hasAvailableUpdate) ...[
|
||||||
|
Text(
|
||||||
|
'Update available: ${addon.installedVersion} \u2192 $availableVersion',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
updateAttempt == null
|
||||||
|
? 'No recent update attempt information is available yet.'
|
||||||
|
: 'Last checked: ${formatUpdateAttemptDate(updateAttempt)}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: checking
|
||||||
|
? null
|
||||||
|
: () => _runUpdateCheck(context, ref, addonId),
|
||||||
|
icon: checking
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.system_update_alt),
|
||||||
|
label: Text(
|
||||||
|
checking ? 'Checking for Updates' : 'Check for Updates',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
|
||||||
|
final latest = storeInfo?.latestVersion.trim();
|
||||||
|
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runUpdateCheck(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String addonId,
|
||||||
|
) async {
|
||||||
|
final outcome = await ref
|
||||||
|
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||||
|
.resolveAvailableUpdate();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
switch (outcome) {
|
||||||
|
case AddonUpdateOutcomeMissing():
|
||||||
|
return;
|
||||||
|
case AddonUpdateOutcomeUpToDate():
|
||||||
|
final result = await ref
|
||||||
|
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||||
|
.triggerAndAwait();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
_reportUpdateResult(context, result, fallback: 'No update available');
|
||||||
|
case AddonUpdateOutcomeAvailable(
|
||||||
|
addon: final fresh,
|
||||||
|
:final availableVersion,
|
||||||
|
):
|
||||||
|
final confirmed = await _confirmUpdateDialog(
|
||||||
|
context,
|
||||||
|
fresh,
|
||||||
|
availableVersion,
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final result = await ref
|
||||||
|
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||||
|
.triggerAndAwait();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
_reportUpdateResult(context, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirmUpdateDialog(
|
||||||
|
BuildContext context,
|
||||||
|
AddonInfo addon,
|
||||||
|
String availableVersion,
|
||||||
|
) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Update available'),
|
||||||
|
content: Text(
|
||||||
|
'Update ${addon.displayName} from '
|
||||||
|
'${addon.installedVersion} to $availableVersion?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Not now'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Update'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportUpdateResult(
|
||||||
|
BuildContext context,
|
||||||
|
AddonUpdateRunResult result, {
|
||||||
|
String? fallback,
|
||||||
|
}) {
|
||||||
|
switch (result) {
|
||||||
|
case AddonUpdateRunDone(:final message):
|
||||||
|
final text = (message != null && message.isNotEmpty) ? message : fallback;
|
||||||
|
if (text != null) showInfoMessage(context, text);
|
||||||
|
case AddonUpdateRunNoRemoteSource():
|
||||||
|
showErrorMessage(
|
||||||
|
context,
|
||||||
|
'This locally installed extension has no remote update source.',
|
||||||
|
);
|
||||||
|
case AddonUpdateRunFailed():
|
||||||
|
showErrorMessage(context, 'Failed to start update check.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DescriptionCard extends StatelessWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const _DescriptionCard({required this.addon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final description = addon.description;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
description.isNotEmpty ? description : 'No description provided.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailsCard extends StatelessWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const _DetailsCard({required this.addon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if ((addon.authorName ?? '').isNotEmpty)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: const Text('Author'),
|
||||||
|
subtitle: Text(addon.authorName!),
|
||||||
|
onTap: (addon.authorUrl ?? '').isEmpty
|
||||||
|
? null
|
||||||
|
: () => launchUrl(Uri.parse(addon.authorUrl!)),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tag_outlined),
|
||||||
|
title: const Text('Version'),
|
||||||
|
subtitle: Text(addon.installedVersion ?? addon.version),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.update_outlined),
|
||||||
|
title: const Text('Last Updated'),
|
||||||
|
subtitle: Text(formatAddonDate(addon.updatedAt)),
|
||||||
|
),
|
||||||
|
if (addon.homepageUrl.isNotEmpty)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.public),
|
||||||
|
title: const Text('Homepage'),
|
||||||
|
subtitle: Text(addon.homepageUrl),
|
||||||
|
trailing: const Icon(Icons.open_in_new),
|
||||||
|
onTap: () => launchUrl(Uri.parse(addon.homepageUrl)),
|
||||||
|
),
|
||||||
|
if (addon.detailUrl.isNotEmpty)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.storefront_outlined),
|
||||||
|
title: const Text('Addon Listing'),
|
||||||
|
subtitle: Text(addon.detailUrl),
|
||||||
|
trailing: const Icon(Icons.open_in_new),
|
||||||
|
onTap: () => launchUrl(Uri.parse(addon.detailUrl)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
|
||||||
|
Future<void> openAddonSettingsFlow(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AddonInfo addon,
|
||||||
|
) async {
|
||||||
|
if (!addon.hasOptionsPage) {
|
||||||
|
await const AddonManagerRoute().push<void>(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addon.openOptionsPageInTab) {
|
||||||
|
final optionsPageUrl = addon.optionsPageUrl;
|
||||||
|
if (optionsPageUrl == null || optionsPageUrl.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GeckoTabService().selectOrAddTabByUrl(
|
||||||
|
url: Uri.parse(optionsPageUrl),
|
||||||
|
ignoreFragment: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
const BrowserRoute().go(context);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddonInternalSettingsRoute(addonId: addon.id).push<void>(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openAddonSettingsFlowById(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String addonId,
|
||||||
|
) async {
|
||||||
|
final addon = await ref.read(addonServiceProvider).getAddonById(addonId);
|
||||||
|
if (addon == null) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await const AddonManagerRoute().push<void>(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await openAddonSettingsFlow(context, ref, addon);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonInternalSettingsScreen extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonInternalSettingsScreen({required this.addonId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||||
|
|
||||||
|
final addon = addonAsync.value;
|
||||||
|
final optionsPageUrl = addon?.optionsPageUrl;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
addon == null
|
||||||
|
? 'Extension Settings'
|
||||||
|
: '${addon.displayName} Settings',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: switch (addonAsync) {
|
||||||
|
AsyncLoading() when addon == null => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
AsyncError(:final error) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'Failed to load extension settings: $error',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_
|
||||||
|
when addon == null ||
|
||||||
|
optionsPageUrl == null ||
|
||||||
|
optionsPageUrl.isEmpty =>
|
||||||
|
const Center(
|
||||||
|
child: Text('This extension does not expose a settings page.'),
|
||||||
|
),
|
||||||
|
_ => _AddonSettingsPlatformView(optionsPageUrl: optionsPageUrl),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonSettingsPlatformView extends StatelessWidget {
|
||||||
|
final String optionsPageUrl;
|
||||||
|
|
||||||
|
const _AddonSettingsPlatformView({required this.optionsPageUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PlatformViewLink(
|
||||||
|
viewType: 'eu.weblibre/addon_settings',
|
||||||
|
surfaceFactory: (context, controller) {
|
||||||
|
return AndroidViewSurface(
|
||||||
|
controller: controller as AndroidViewController,
|
||||||
|
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
|
||||||
|
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCreatePlatformView: (params) {
|
||||||
|
final controller = PlatformViewsService.initExpensiveAndroidView(
|
||||||
|
id: params.id,
|
||||||
|
viewType: 'eu.weblibre/addon_settings',
|
||||||
|
layoutDirection: TextDirection.ltr,
|
||||||
|
creationParams: <String, Object?>{'optionsPageUrl': optionsPageUrl},
|
||||||
|
creationParamsCodec: const StandardMessageCodec(),
|
||||||
|
);
|
||||||
|
controller.addOnPlatformViewCreatedListener(
|
||||||
|
params.onPlatformViewCreated,
|
||||||
|
);
|
||||||
|
unawaited(controller.create());
|
||||||
|
return controller;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class AddonManagerScreen extends ConsumerWidget {
|
||||||
|
const AddonManagerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addonsAsync = ref.watch(addonListProvider);
|
||||||
|
|
||||||
|
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Extensions'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: addonsAsync.isLoading ? null : refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
_TriggerAllUpdatesButton(
|
||||||
|
enabled: addonsAsync.maybeWhen(
|
||||||
|
data: (addons) =>
|
||||||
|
addons.any((a) => a.isInstalled && a.isSupported),
|
||||||
|
orElse: () => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: addonsAsync.when(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
|
skipError: true,
|
||||||
|
data: (addons) => RefreshIndicator(
|
||||||
|
onRefresh: refresh,
|
||||||
|
child: _AddonList(addons: addons),
|
||||||
|
),
|
||||||
|
error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TriggerAllUpdatesButton extends ConsumerWidget {
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const _TriggerAllUpdatesButton({required this.enabled});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final busy = ref.watch(
|
||||||
|
bulkAddonUpdateProvider.select((value) => value.isLoading),
|
||||||
|
);
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
onPressed: enabled && !busy
|
||||||
|
? () async {
|
||||||
|
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(
|
||||||
|
context,
|
||||||
|
'Background update checks started for installed extensions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.system_update_alt),
|
||||||
|
tooltip: 'Check all installed extensions for updates',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonList extends StatelessWidget {
|
||||||
|
final List<AddonInfo> addons;
|
||||||
|
|
||||||
|
const _AddonList({required this.addons});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = addons
|
||||||
|
.where((a) => a.isInstalled && a.isSupported && a.isEnabled)
|
||||||
|
.toList();
|
||||||
|
final disabled = addons
|
||||||
|
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
|
||||||
|
.toList();
|
||||||
|
final recommended = addons.where((a) => !a.isInstalled).toList();
|
||||||
|
final unsupported = addons
|
||||||
|
.where((a) => a.isInstalled && !a.isSupported)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
const Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.info_outline),
|
||||||
|
title: Text('Addon updates run in the background'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Use each extension detail screen to view its last update result or trigger a manual check.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (enabled.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _Section(title: 'Enabled'),
|
||||||
|
for (final addon in enabled) _AddonCard(addon: addon),
|
||||||
|
],
|
||||||
|
if (disabled.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _Section(title: 'Disabled'),
|
||||||
|
for (final addon in disabled) _AddonCard(addon: addon),
|
||||||
|
],
|
||||||
|
if (recommended.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _Section(title: 'Available'),
|
||||||
|
for (final addon in recommended)
|
||||||
|
_AddonCard(
|
||||||
|
addon: addon,
|
||||||
|
action: _InstallAction(addon: addon),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (unsupported.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _Section(title: 'Unsupported'),
|
||||||
|
for (final addon in unsupported)
|
||||||
|
_AddonCard(
|
||||||
|
addon: addon,
|
||||||
|
action: _UninstallAction(addon: addon),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (addons.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 48),
|
||||||
|
child: Center(child: Text('No extensions available right now.')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InstallAction extends ConsumerWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const _InstallAction({required this.addon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||||
|
return FilledButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await ref.read(addonListProvider.notifier).install(addon);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(context, '${addon.displayName} installed');
|
||||||
|
},
|
||||||
|
child: const Text('Install'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UninstallAction extends ConsumerWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const _UninstallAction({required this.addon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Remove extension',
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await ref.read(addonListProvider.notifier).uninstall(addon);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(context, '${addon.displayName} removed');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Section extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const _Section({required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonCard extends ConsumerWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
const _AddonCard({required this.addon, this.action});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: busy
|
||||||
|
? null
|
||||||
|
: () => AddonDetailsRoute(addonId: addon.id).push<void>(context),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AddonIconView(addon: addon),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
addon.displayName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
if ((addon.summary ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(addon.summary!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (addon.isAllowedInPrivateBrowsing)
|
||||||
|
const Chip(label: Text('Private Browsing')),
|
||||||
|
if (addon.ratingAverage != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.star, size: 16),
|
||||||
|
label: Text(
|
||||||
|
addon.ratingAverage!.toStringAsFixed(1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
action ?? const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (addon.statusBannerMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AddonStatusBanner(addon: addon),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddonLoadError extends StatelessWidget {
|
||||||
|
final Object? error;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const _AddonLoadError({required this.error, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Failed to load extensions'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(error.toString(), textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: onRetry, child: const Text('Retry')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
|
||||||
|
const _permissionsLearnMoreUrl =
|
||||||
|
'https://support.mozilla.org/kb/permission-request-messages-firefox-extensions';
|
||||||
|
|
||||||
|
class AddonPermissionsScreen extends ConsumerWidget {
|
||||||
|
final String addonId;
|
||||||
|
|
||||||
|
const AddonPermissionsScreen({required this.addonId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||||
|
final addon = addonAsync.value;
|
||||||
|
|
||||||
|
final permissions = addon?.translatedPermissions.toList() ?? <String>[];
|
||||||
|
permissions.sort();
|
||||||
|
|
||||||
|
final dataCollection =
|
||||||
|
addon?.translatedRequiredDataCollectionPermissions.toList() ??
|
||||||
|
<String>[];
|
||||||
|
dataCollection.sort();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
addon == null
|
||||||
|
? 'Extension Permissions'
|
||||||
|
: '${addon.displayName} Permissions',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: switch (addonAsync) {
|
||||||
|
AsyncLoading() when addon == null => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
AsyncError(:final error) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'Failed to load extension permissions: $error',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ when addon == null => const Center(
|
||||||
|
child: Text('This extension could not be found.'),
|
||||||
|
),
|
||||||
|
_ => ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (permissions.isEmpty && dataCollection.isEmpty)
|
||||||
|
const Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.verified_user_outlined),
|
||||||
|
title: Text('No special permissions listed'),
|
||||||
|
subtitle: Text(
|
||||||
|
'This extension does not currently expose any translated permission details.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (permissions.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'Permissions',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final permission in permissions)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.check_circle_outline),
|
||||||
|
title: Text(permission),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (dataCollection.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'Required Data Collection',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final permission in dataCollection)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.data_usage_outlined),
|
||||||
|
title: Text(permission),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await launchUrl(Uri.parse(_permissionsLearnMoreUrl));
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
label: const Text('Learn More'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||||
|
|
||||||
|
class AddonIconView extends StatelessWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const AddonIconView({required this.addon, this.size = 40, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bytes = addon.icon;
|
||||||
|
final borderRadius = BorderRadius.circular(12);
|
||||||
|
|
||||||
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
child: Image.memory(
|
||||||
|
bytes,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => _FallbackIcon(size: size),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _FallbackIcon(size: size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FallbackIcon extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _FallbackIcon({required this.size});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonStatusBanner extends StatelessWidget {
|
||||||
|
final AddonInfo addon;
|
||||||
|
|
||||||
|
const AddonStatusBanner({required this.addon, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final message = addon.statusBannerMessage;
|
||||||
|
if (message == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final isWarning = addon.disabledReason == AddonDisabledReason.softBlocked;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final background = isWarning
|
||||||
|
? theme.colorScheme.tertiaryContainer
|
||||||
|
: theme.colorScheme.errorContainer;
|
||||||
|
final foreground = isWarning
|
||||||
|
? theme.colorScheme.onTertiaryContainer
|
||||||
|
: theme.colorScheme.onErrorContainer;
|
||||||
|
final icon = isWarning ? Icons.warning_amber_rounded : Icons.error_outline;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: foreground, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatAddonDate(String raw) {
|
||||||
|
final parsed = DateTime.tryParse(raw);
|
||||||
|
if (parsed == null) {
|
||||||
|
return raw.isEmpty ? 'Unknown' : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateFormat.yMMMd().format(parsed.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatUpdateAttemptDate(AddonUpdateAttemptInfo attempt) {
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
attempt.dateMillisecondsSinceEpoch,
|
||||||
|
).toLocal();
|
||||||
|
return DateFormat.yMMMd().add_jm().format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatUpdateAttemptStatus(AddonUpdateAttemptInfo? attempt) {
|
||||||
|
return switch (attempt?.status) {
|
||||||
|
AddonUpdateStatus.successfullyUpdated =>
|
||||||
|
attempt?.message?.isNotEmpty == true
|
||||||
|
? attempt!.message!
|
||||||
|
: 'Updated successfully',
|
||||||
|
AddonUpdateStatus.noUpdateAvailable => 'No update available',
|
||||||
|
AddonUpdateStatus.notInstalled => 'Extension not installed',
|
||||||
|
AddonUpdateStatus.error =>
|
||||||
|
attempt?.message?.isNotEmpty == true
|
||||||
|
? 'Update failed: ${attempt!.message}'
|
||||||
|
: 'Update failed',
|
||||||
|
null => 'No update checks recorded yet',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
|
||||||
|
|
||||||
|
class PinnedAddonBar extends ConsumerWidget {
|
||||||
|
const PinnedAddonBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pinnedIds = ref.watch(pinnedAddonIdsProvider);
|
||||||
|
if (pinnedIds.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final extensions = ref.watch(
|
||||||
|
webExtensionsStateProvider(
|
||||||
|
WebExtensionActionType.browser,
|
||||||
|
).select((value) => value.values.toList()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pinned = extensions
|
||||||
|
.where((e) => pinnedIds.contains(e.extensionId))
|
||||||
|
.toList();
|
||||||
|
if (pinned.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final extension in pinned)
|
||||||
|
InkResponse(
|
||||||
|
radius: 22,
|
||||||
|
onTap: () async {
|
||||||
|
await ref
|
||||||
|
.read(addonServiceProvider)
|
||||||
|
.invokeAddonAction(
|
||||||
|
extension.extensionId,
|
||||||
|
WebExtensionActionType.browser,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLongPress: () async {
|
||||||
|
await AddonDetailsRoute(
|
||||||
|
addonId: extension.extensionId,
|
||||||
|
).push<void>(context);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: ExtensionBadgeIcon(extension),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-2
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<bool> build() async {
|
FutureOr<bool> build() async {
|
||||||
final prefs =
|
final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
||||||
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
|
||||||
final pref = prefs[_signatureRequiredPref];
|
final pref = prefs[_signatureRequiredPref];
|
||||||
final allowUnsigned = pref?.value == false;
|
final allowUnsigned = pref?.value == false;
|
||||||
|
|
||||||
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class AddonAutoUpdate extends _$AddonAutoUpdate {
|
||||||
|
Future<void> setEnabled({required bool enabled}) async {
|
||||||
|
final service = ref.read(addonServiceProvider);
|
||||||
|
await service.setAddonAutoUpdateEnabled(enabled: enabled);
|
||||||
|
|
||||||
|
state = AsyncData(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> build() {
|
||||||
|
return ref.read(addonServiceProvider).isAddonAutoUpdateEnabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class BrowserAddonService extends _$BrowserAddonService {
|
class BrowserAddonService extends _$BrowserAddonService {
|
||||||
Future<Uri> getAddonXpiUrl(String guid) async {
|
Future<Uri> getAddonXpiUrl(String guid) async {
|
||||||
|
|||||||
+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)
|
@ProviderFor(BrowserAddonService)
|
||||||
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
|
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
|||||||
.installFromFile(selectedFile.value!);
|
.installFromFile(selectedFile.value!);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showInfoMessage(context, 'Extension installed successfully');
|
showInfoMessage(
|
||||||
|
context,
|
||||||
|
'Extension installed. Automatic updates are disabled for this local version.',
|
||||||
|
);
|
||||||
context.pop(true);
|
context.pop(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -149,6 +152,11 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Extensions installed from a local XPI stay pinned to that version and will not update automatically.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
if (errorMessage.value != null) ...[
|
if (errorMessage.value != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
+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/controllers/toolbar_visibility.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
|
||||||
@@ -305,6 +306,18 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final addonService = ref.watch(addonServiceProvider);
|
||||||
|
useOnStreamChange(
|
||||||
|
addonService.popupStream,
|
||||||
|
onData: (event) async {
|
||||||
|
await showAddonPopupBottomSheet(
|
||||||
|
context,
|
||||||
|
extensionId: event.extensionId,
|
||||||
|
extensionName: event.extensionName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((previous, current) {
|
useOnAppLifecycleStateChange((previous, current) {
|
||||||
switch (current) {
|
switch (current) {
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
|
|||||||
+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/design/app_colors.dart';
|
||||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
|
|||||||
WebExtensionActionType.browser,
|
WebExtensionActionType.browser,
|
||||||
).select((value) => value.values.toList()),
|
).select((value) => value.values.toList()),
|
||||||
);
|
);
|
||||||
|
final rootContext = Navigator.of(context, rootNavigator: true).context;
|
||||||
Future<void> openExtensionSettings(String extensionId) async {
|
Future<void> openExtensionSettings(String extensionId) async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await addonService.startAddonSettingsActivity(extensionId);
|
await openAddonSettingsFlowById(rootContext, ref, extensionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildMenuCard(
|
return _buildMenuCard(
|
||||||
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
|
|||||||
icon: MdiIcons.puzzleEdit,
|
icon: MdiIcons.puzzleEdit,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await addonService.startAddonManagerActivity();
|
await const AddonManagerRoute().push<void>(rootContext);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildSubTile(
|
_buildSubTile(
|
||||||
|
|||||||
+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:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
: const AppBarTitle()
|
: const AppBarTitle()
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
|
const PinnedAddonBar(),
|
||||||
if (isSmallWebMode)
|
if (isSmallWebMode)
|
||||||
ReaderButton(
|
ReaderButton(
|
||||||
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
||||||
|
|||||||
+135
-51
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
children: const [
|
children: const [
|
||||||
SettingSection(name: 'Extensions'),
|
SettingSection(name: 'Extensions'),
|
||||||
|
_ManageExtensionsTile(),
|
||||||
_InstallLocalAddonTile(),
|
_InstallLocalAddonTile(),
|
||||||
_AddonCollectionTile(),
|
_AddonCollectionTile(),
|
||||||
|
SettingSection(name: 'Updates'),
|
||||||
|
_AutoUpdateTile(),
|
||||||
SettingSection(name: 'Security'),
|
SettingSection(name: 'Security'),
|
||||||
_AllowUnsignedExtensionsTile(),
|
_AllowUnsignedExtensionsTile(),
|
||||||
],
|
],
|
||||||
@@ -59,6 +62,34 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ManageExtensionsTile extends StatelessWidget {
|
||||||
|
const _ManageExtensionsTile();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomListTile(
|
||||||
|
title: 'Manage Extensions',
|
||||||
|
subtitle:
|
||||||
|
'Browse installed, disabled, available, and unsupported extensions',
|
||||||
|
prefix: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16.0),
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.puzzleEdit,
|
||||||
|
size: 24,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
suffix: FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await const AddonManagerRoute().push<void>(context);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
label: const Text('Open'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _InstallLocalAddonTile extends StatelessWidget {
|
class _InstallLocalAddonTile extends StatelessWidget {
|
||||||
const _InstallLocalAddonTile();
|
const _InstallLocalAddonTile();
|
||||||
|
|
||||||
@@ -113,6 +144,45 @@ class _AddonCollectionTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AutoUpdateTile extends ConsumerWidget {
|
||||||
|
const _AutoUpdateTile();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final autoUpdate = ref.watch(addonAutoUpdateProvider);
|
||||||
|
|
||||||
|
return autoUpdate.when(
|
||||||
|
data: (enabled) => SwitchListTile.adaptive(
|
||||||
|
title: const Text('Automatic updates'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Automatically check for and install extension updates every 12 hours',
|
||||||
|
),
|
||||||
|
secondary: const Icon(Icons.system_update_alt),
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await ref
|
||||||
|
.read(addonAutoUpdateProvider.notifier)
|
||||||
|
.setEnabled(enabled: value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
loading: () => const SwitchListTile.adaptive(
|
||||||
|
title: Text('Automatic updates'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Automatically check for and install extension updates every 12 hours',
|
||||||
|
),
|
||||||
|
secondary: Icon(Icons.system_update_alt),
|
||||||
|
value: true,
|
||||||
|
onChanged: null,
|
||||||
|
),
|
||||||
|
error: (error, stack) => ListTile(
|
||||||
|
leading: const Icon(Icons.error_outline),
|
||||||
|
title: const Text('Automatic updates'),
|
||||||
|
subtitle: Text('Failed to load: $error'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
||||||
const _AllowUnsignedExtensionsTile();
|
const _AllowUnsignedExtensionsTile();
|
||||||
|
|
||||||
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
|
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
|
||||||
|
|
||||||
return Column(
|
return allowUnsigned.when(
|
||||||
children: [
|
data: (allowed) => Column(
|
||||||
SwitchListTile.adaptive(
|
children: [
|
||||||
title: const Text('Allow unsigned extensions'),
|
SwitchListTile.adaptive(
|
||||||
subtitle: const Text(
|
title: const Text('Allow unsigned extensions'),
|
||||||
'Unsigned extensions have not been verified by Mozilla',
|
subtitle: const Text(
|
||||||
),
|
'Unsigned extensions have not been verified by Mozilla',
|
||||||
secondary: const Icon(Icons.extension_off),
|
),
|
||||||
value: allowUnsigned.value ?? false,
|
secondary: const Icon(Icons.extension_off),
|
||||||
onChanged: allowUnsigned.isLoading
|
value: allowed,
|
||||||
? null
|
onChanged: (value) async {
|
||||||
: (value) async {
|
if (value) {
|
||||||
if (value) {
|
final confirmed = await _showAllowUnsignedConfirmationDialog(
|
||||||
final confirmed =
|
|
||||||
await _showAllowUnsignedConfirmationDialog(context);
|
|
||||||
if (confirmed != true) return;
|
|
||||||
}
|
|
||||||
await ref
|
|
||||||
.read(allowUnsignedExtensionsProvider.notifier)
|
|
||||||
.setAllowUnsigned(allow: value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (allowUnsigned.value == true)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
context,
|
||||||
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
);
|
||||||
borderRadius: BorderRadius.circular(8),
|
if (confirmed != true) return;
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.error),
|
}
|
||||||
),
|
await ref
|
||||||
child: Row(
|
.read(allowUnsignedExtensionsProvider.notifier)
|
||||||
children: [
|
.setAllowUnsigned(allow: value);
|
||||||
Icon(
|
},
|
||||||
Icons.warning_amber,
|
),
|
||||||
|
if (allowed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
size: 20,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Expanded(
|
child: Row(
|
||||||
child: Text(
|
children: [
|
||||||
'Only install unsigned extensions from sources you trust. '
|
Icon(
|
||||||
'They may contain malicious code.',
|
Icons.warning_amber,
|
||||||
style: TextStyle(
|
color: Theme.of(context).colorScheme.error,
|
||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
size: 20,
|
||||||
fontSize: 12,
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Only install unsigned extensions from sources you trust. '
|
||||||
|
'They may contain malicious code.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
loading: () => const SwitchListTile.adaptive(
|
||||||
|
title: Text('Allow unsigned extensions'),
|
||||||
|
subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
|
||||||
|
secondary: Icon(Icons.extension_off),
|
||||||
|
value: false,
|
||||||
|
onChanged: null,
|
||||||
|
),
|
||||||
|
error: (error, stack) => ListTile(
|
||||||
|
leading: const Icon(Icons.error_outline),
|
||||||
|
title: const Text('Allow unsigned extensions'),
|
||||||
|
subtitle: Text('Failed to load: $error'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,4 +347,3 @@ class _AllowUnsignedConfirmationDialog extends HookWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.core.content.edit
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity
|
|
||||||
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
|
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
|
||||||
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
|
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||||
@@ -555,14 +554,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
protected open fun onEngineSetupComplete() {}
|
protected open fun onEngineSetupComplete() {}
|
||||||
|
|
||||||
private fun openPopup(webExtensionState: WebExtensionState) {
|
private fun openPopup(webExtensionState: WebExtensionState) {
|
||||||
val intent = Intent(
|
components.addonEvents.onWebExtensionPopupRequested(
|
||||||
components.profileApplicationContext,
|
webExtensionState.id,
|
||||||
WebExtensionActionPopupActivity::class.java
|
webExtensionState.name ?: "",
|
||||||
)
|
) {}
|
||||||
intent.putExtra("web_extension_id", webExtensionState.id)
|
|
||||||
intent.putExtra("web_extension_name", webExtensionState.name)
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
|
|||||||
+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.GeckoViewportEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
|
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
||||||
|
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
|
||||||
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
|
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
|
||||||
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
|
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
|
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
|
||||||
@@ -268,7 +269,23 @@ object GlobalComponents {
|
|||||||
},
|
},
|
||||||
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
|
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
|
||||||
onExtensionsLoaded = { extensions ->
|
onExtensionsLoaded = { extensions ->
|
||||||
newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
|
val addonPrefs = AddonPrefs.get(applicationContext)
|
||||||
|
val autoUpdateEnabled =
|
||||||
|
addonPrefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
|
||||||
|
val autoUpdateDisabledAddonIds =
|
||||||
|
addonPrefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet())
|
||||||
|
?: emptySet()
|
||||||
|
val localFileAddonIds =
|
||||||
|
addonPrefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet())
|
||||||
|
?: emptySet()
|
||||||
|
if (autoUpdateEnabled) {
|
||||||
|
newComponents.core.addonUpdater.registerForFutureUpdates(
|
||||||
|
extensions.filterNot { extension ->
|
||||||
|
autoUpdateDisabledAddonIds.contains(extension.id) ||
|
||||||
|
localFileAddonIds.contains(extension.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
newComponents.core.supportedAddonsChecker.registerForChecks()
|
newComponents.core.supportedAddonsChecker.registerForChecks()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
-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
|
package eu.weblibre.flutter_mozilla_components.api
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||||
import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity
|
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
|
||||||
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
|
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
|
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import mozilla.components.concept.fetch.MutableHeaders
|
||||||
|
import mozilla.components.concept.fetch.Request
|
||||||
import mozilla.components.concept.engine.webextension.InstallationMethod
|
import mozilla.components.concept.engine.webextension.InstallationMethod
|
||||||
|
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
|
||||||
|
import mozilla.components.feature.addons.Addon
|
||||||
|
import mozilla.components.feature.addons.update.AddonUpdater
|
||||||
|
import mozilla.components.feature.addons.update.DefaultAddonUpdater
|
||||||
|
import mozilla.components.feature.addons.ui.displayName
|
||||||
|
import mozilla.components.feature.addons.ui.summary
|
||||||
|
import mozilla.components.feature.addons.ui.translateDescription
|
||||||
|
import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
||||||
private val components by lazy {
|
private val components by lazy {
|
||||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||||
}
|
}
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val updateAttemptStorage by lazy {
|
||||||
override fun startAddonManagerActivity() {
|
DefaultAddonUpdater.UpdateAttemptStorage(context.applicationContext)
|
||||||
val intent = Intent(context, AddonsActivity::class.java)
|
}
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
private val prefs by lazy {
|
||||||
context.startActivity(intent)
|
AddonPrefs.get(context.applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startAddonSettingsActivity(extensionId: String) {
|
companion object {
|
||||||
|
private const val LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE =
|
||||||
|
"No remote update source is available for this locally installed extension."
|
||||||
|
private const val LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE =
|
||||||
|
"Update downloaded and will be applied after restarting the app."
|
||||||
|
private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
|
||||||
|
private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAddons(allowCache: Boolean, callback: (Result<List<AddonInfo>>) -> Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val addon = runCatching {
|
runCatching {
|
||||||
components.core.addonManager.getAddons()
|
components.core.addonManager.getAddons(allowCache = allowCache)
|
||||||
.find { it.id == extensionId }
|
.map { addon ->
|
||||||
}.getOrNull()
|
addon.toPigeon(
|
||||||
|
context = context,
|
||||||
if (addon == null) {
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
|
||||||
withContext(Dispatchers.Main) {
|
isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
|
||||||
startAddonManagerActivity()
|
)
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val optionsPageUrl = addon.installedState?.optionsPageUrl
|
|
||||||
if (optionsPageUrl.isNullOrEmpty()) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
startAddonManagerActivity()
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (addon.installedState?.openOptionsPageInTab == true) {
|
|
||||||
components.useCases.tabsUseCases.selectOrAddTab(
|
|
||||||
url = optionsPageUrl,
|
|
||||||
ignoreFragment = true,
|
|
||||||
)
|
|
||||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
|
||||||
launchIntent?.addFlags(
|
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
|
||||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
|
||||||
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
|
||||||
)
|
|
||||||
if (launchIntent != null) {
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
} else {
|
|
||||||
startAddonManagerActivity()
|
|
||||||
}
|
}
|
||||||
} else {
|
}.fold(
|
||||||
val intent = Intent(context, AddonInternalSettingsActivity::class.java)
|
onSuccess = { callback(Result.success(it)) },
|
||||||
intent.putExtra("add_on", addon)
|
onFailure = { callback(Result.failure(it)) },
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
)
|
||||||
context.startActivity(intent)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun getAddonById(
|
||||||
|
addonId: String,
|
||||||
|
allowCache: Boolean,
|
||||||
|
callback: (Result<AddonInfo?>) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val installedAddon = components.core.addonManager.getAddonByID(addonId)
|
||||||
|
(installedAddon ?: components.core.addonManager.getAddons(allowCache = allowCache)
|
||||||
|
.find { it.id == addonId })?.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addonId),
|
||||||
|
isLocalFileInstalled = isLocalFileInstalledAddon(addonId),
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { callback(Result.success(it)) },
|
||||||
|
onFailure = { callback(Result.failure(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAddonStoreInfo(addonId: String, callback: (Result<AddonStoreInfo?>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
fetchAddonStoreInfo(addonId)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { callback(Result.success(it)) },
|
||||||
|
onFailure = { callback(Result.failure(it)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
|
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
|
||||||
when(actionType) {
|
scope.launch {
|
||||||
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
|
withContext(Dispatchers.Main.immediate) {
|
||||||
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
|
when(actionType) {
|
||||||
|
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
|
||||||
|
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enableAddon(addonId: String, callback: (Result<AddonInfo>) -> Unit) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
components.core.addonManager.enableAddon(
|
||||||
|
addon,
|
||||||
|
onSuccess = { updatedAddon ->
|
||||||
|
result(
|
||||||
|
Result.success(
|
||||||
|
updatedAddon.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
|
||||||
|
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = { throwable ->
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disableAddon(addonId: String, callback: (Result<AddonInfo>) -> Unit) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
components.core.addonManager.disableAddon(
|
||||||
|
addon,
|
||||||
|
onSuccess = { updatedAddon ->
|
||||||
|
result(
|
||||||
|
Result.success(
|
||||||
|
updatedAddon.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
|
||||||
|
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = { throwable ->
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAddonAllowedInPrivateBrowsing(
|
||||||
|
addonId: String,
|
||||||
|
allowed: Boolean,
|
||||||
|
callback: (Result<AddonInfo>) -> Unit,
|
||||||
|
) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
components.core.addonManager.setAddonAllowedInPrivateBrowsing(
|
||||||
|
addon,
|
||||||
|
allowed,
|
||||||
|
onSuccess = { updatedAddon ->
|
||||||
|
result(
|
||||||
|
Result.success(
|
||||||
|
updatedAddon.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
|
||||||
|
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = { throwable ->
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAddonAutoUpdateEnabledForAddon(
|
||||||
|
addonId: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
callback: (Result<AddonInfo>) -> Unit,
|
||||||
|
) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
if (enabled && isLocalFileInstalledAddon(addon.id)) {
|
||||||
|
result(
|
||||||
|
Result.success(
|
||||||
|
addon.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = false,
|
||||||
|
isLocalFileInstalled = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return@withInstalledAddon
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddonAutoUpdateEnabledForAddonInternal(addon.id, enabled)
|
||||||
|
result(
|
||||||
|
Result.success(
|
||||||
|
addon.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
|
||||||
|
isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstallAddon(addonId: String, callback: (Result<Unit>) -> Unit) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
components.core.addonManager.uninstallAddon(
|
||||||
|
addon,
|
||||||
|
onSuccess = {
|
||||||
|
clearAddonAutoUpdatePreference(addon.id)
|
||||||
|
clearLocalFileInstalledAddon(addon.id)
|
||||||
|
clearManualUpdateAttempt(addon.id)
|
||||||
|
result(Result.success(Unit))
|
||||||
|
},
|
||||||
|
onError = { _, throwable ->
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun triggerAddonUpdate(
|
||||||
|
addonId: String,
|
||||||
|
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
|
||||||
|
) {
|
||||||
|
if (isLocalFileInstalledAddon(addonId)) {
|
||||||
|
runLocalFileAddonUpdate(addonId, callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
runManagedAddonUpdate(addonId) { attempt ->
|
||||||
|
callback(Result.success(attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
callback(Result.failure(throwable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun triggerAllAddonUpdates(callback: (Result<Unit>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val addons = components.core.addonManager.getAddons()
|
||||||
|
.filter { it.isInstalled() && it.isSupported() }
|
||||||
|
.filter { addon -> isAddonAutoUpdateEnabledForAddon(addon.id) }
|
||||||
|
.filterNot { addon -> isLocalFileInstalledAddon(addon.id) }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
addons.forEach { addon ->
|
||||||
|
scheduleManagedAddonUpdate(addon.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
callback(Result.failure(throwable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLastAddonUpdateAttempt(
|
||||||
|
addonId: String,
|
||||||
|
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val updaterAttempt = updateAttemptStorage.findUpdateAttemptBy(addonId)?.toPigeon()
|
||||||
|
val manualAttempt = getManualUpdateAttempt(addonId)
|
||||||
|
listOfNotNull(updaterAttempt, manualAttempt)
|
||||||
|
.maxByOrNull { it.dateMillisecondsSinceEpoch }
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { callback(Result.success(it)) },
|
||||||
|
onFailure = { callback(Result.failure(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAddonAutoUpdateEnabled(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
callback(Result.success(prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAddonAutoUpdateEnabled(enabled: Boolean, callback: (Result<Unit>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
prefs.edit().putBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, enabled).apply()
|
||||||
|
|
||||||
|
val addons = components.core.addonManager.getAddons()
|
||||||
|
.filter { it.isInstalled() && it.isSupported() }
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
if (enabled) {
|
||||||
|
addons.forEach { addon ->
|
||||||
|
updateAddonAutoUpdateRegistration(addon.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addons.forEach { addon ->
|
||||||
|
components.core.addonUpdater.unregisterForFutureUpdates(addon.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
callback(Result.failure(throwable))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun installAddon(url: String, callback: (Result<Unit>) -> Unit) {
|
override fun installAddon(url: String, callback: (Result<Unit>) -> Unit) {
|
||||||
val installMethod = if (url.startsWith("file://")) {
|
val isLocalFileInstall = url.startsWith("file://")
|
||||||
|
val installMethod = if (isLocalFileInstall) {
|
||||||
InstallationMethod.FROM_FILE
|
InstallationMethod.FROM_FILE
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
components.core.addonManager.installAddon(
|
scope.launch {
|
||||||
url = url,
|
try {
|
||||||
installationMethod = installMethod,
|
withContext(Dispatchers.Main.immediate) {
|
||||||
onSuccess = { _ ->
|
performAddonInstall(url, isLocalFileInstall, callback)
|
||||||
callback(Result.success(Unit))
|
}
|
||||||
},
|
} catch (throwable: Throwable) {
|
||||||
onError = { e ->
|
callback(Result.failure(throwable))
|
||||||
callback(Result.failure(e))
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> withInstalledAddon(
|
||||||
|
addonId: String,
|
||||||
|
callback: (Result<T>) -> Unit,
|
||||||
|
block: suspend (Addon, (Result<T>) -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
val addon = runCatching {
|
||||||
|
components.core.addonManager.getAddonByID(addonId)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (addon == null) {
|
||||||
|
callback(Result.failure(IllegalStateException("Addon $addonId not found")))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
block(addon, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAutoUpdateEnabled(): Boolean {
|
||||||
|
return prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAddonAutoUpdateEnabledForAddon(addonId: String): Boolean {
|
||||||
|
return !getAddonAutoUpdateDisabledIds().contains(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAutoUpdateEffectivelyEnabledForAddon(addonId: String): Boolean {
|
||||||
|
return isAddonAutoUpdateEnabledForAddon(addonId) && !isLocalFileInstalledAddon(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLocalFileInstalledAddon(addonId: String): Boolean {
|
||||||
|
return getLocalFileAddonIds().contains(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAddonAutoUpdateDisabledIds(): Set<String> {
|
||||||
|
return prefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet()) ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLocalFileAddonIds(): Set<String> {
|
||||||
|
return prefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet()) ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchAddonStoreInfo(addonId: String): AddonStoreInfo? {
|
||||||
|
val response = components.core.client.fetch(
|
||||||
|
Request(
|
||||||
|
url = addonStoreInfoUrl(addonId),
|
||||||
|
method = Request.Method.GET,
|
||||||
|
headers = MutableHeaders("Accept" to "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (response.status !in 200..299) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseBody = response.body.useStream { stream ->
|
||||||
|
String(stream.readAllBytes(), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
val json = JSONObject(responseBody)
|
||||||
|
val currentVersion = json.optJSONObject("current_version") ?: return null
|
||||||
|
val latestVersion = currentVersion.optString("version")
|
||||||
|
val latestXpiUrl = currentVersion.optJSONObject("file")?.optString("url").orEmpty()
|
||||||
|
if (latestVersion.isBlank() || latestXpiUrl.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddonStoreInfo(
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
latestXpiUrl = latestXpiUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun addonStoreInfoUrl(addonId: String): String {
|
||||||
|
val baseUrl = components.addonCollection?.serverURL?.trimEnd('/') ?: DEFAULT_AMO_SERVER_URL
|
||||||
|
return "$baseUrl/api/v5/addons/addon/$addonId/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveManualUpdateAttempt(
|
||||||
|
addonId: String,
|
||||||
|
status: AddonUpdateStatus,
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(manualAttemptTimestampKey(addonId), System.currentTimeMillis())
|
||||||
|
.putString(manualAttemptStatusKey(addonId), status.name)
|
||||||
|
.putString(manualAttemptMessageKey(addonId), message)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getManualUpdateAttempt(addonId: String): AddonUpdateAttemptInfo? {
|
||||||
|
val timestamp = prefs.getLong(manualAttemptTimestampKey(addonId), -1L)
|
||||||
|
if (timestamp < 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = prefs.getString(manualAttemptStatusKey(addonId), null)
|
||||||
|
?.let { runCatching { AddonUpdateStatus.valueOf(it) }.getOrNull() }
|
||||||
|
|
||||||
|
return AddonUpdateAttemptInfo(
|
||||||
|
addonId = addonId,
|
||||||
|
dateMillisecondsSinceEpoch = timestamp,
|
||||||
|
status = status,
|
||||||
|
message = prefs.getString(manualAttemptMessageKey(addonId), null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearManualUpdateAttempt(addonId: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(manualAttemptTimestampKey(addonId))
|
||||||
|
.remove(manualAttemptStatusKey(addonId))
|
||||||
|
.remove(manualAttemptMessageKey(addonId))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun manualAttemptTimestampKey(addonId: String): String {
|
||||||
|
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun manualAttemptStatusKey(addonId: String): String {
|
||||||
|
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.status"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun manualAttemptMessageKey(addonId: String): String {
|
||||||
|
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.message"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performAddonInstall(
|
||||||
|
url: String,
|
||||||
|
isLocalFileInstall: Boolean,
|
||||||
|
callback: (Result<Unit>) -> Unit,
|
||||||
|
) {
|
||||||
|
val installationMethod = if (isLocalFileInstall) InstallationMethod.FROM_FILE else null
|
||||||
|
|
||||||
|
components.core.addonManager.installAddon(
|
||||||
|
url = url,
|
||||||
|
installationMethod = installationMethod,
|
||||||
|
onSuccess = { installedAddon ->
|
||||||
|
applyInstallUpdatePolicy(installedAddon.id, isLocalFileInstall)
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
callback(Result.failure(error))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyInstallUpdatePolicy(addonId: String, isLocalFileInstall: Boolean) {
|
||||||
|
if (isLocalFileInstall) {
|
||||||
|
markAddonAsLocalFileInstalled(addonId)
|
||||||
|
setAddonAutoUpdateEnabledForAddonInternal(addonId, false)
|
||||||
|
} else {
|
||||||
|
clearLocalFileInstalledAddon(addonId)
|
||||||
|
updateAddonAutoUpdateRegistration(addonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runLocalFileAddonUpdate(
|
||||||
|
addonId: String,
|
||||||
|
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
|
||||||
|
) {
|
||||||
|
withInstalledAddon(addonId, callback) { addon, result ->
|
||||||
|
val storeInfo = fetchAddonStoreInfo(addonId)
|
||||||
|
if (storeInfo == null) {
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.ERROR,
|
||||||
|
message = LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE,
|
||||||
|
)
|
||||||
|
result(Result.failure(IllegalStateException(LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE)))
|
||||||
|
return@withInstalledAddon
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addon.installedState?.version == storeInfo.latestVersion) {
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.NO_UPDATE_AVAILABLE,
|
||||||
|
)
|
||||||
|
result(Result.success(getManualUpdateAttempt(addonId)))
|
||||||
|
return@withInstalledAddon
|
||||||
|
}
|
||||||
|
|
||||||
|
components.core.addonManager.uninstallAddon(
|
||||||
|
addon,
|
||||||
|
onSuccess = {
|
||||||
|
performAddonInstall(
|
||||||
|
url = storeInfo.latestXpiUrl,
|
||||||
|
isLocalFileInstall = false,
|
||||||
|
callback = { installResult ->
|
||||||
|
installResult.fold(
|
||||||
|
onSuccess = {
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
|
||||||
|
)
|
||||||
|
result(Result.success(getManualUpdateAttempt(addonId)))
|
||||||
|
},
|
||||||
|
onFailure = { throwable ->
|
||||||
|
if (isPostponedInstallException(throwable)) {
|
||||||
|
clearLocalFileInstalledAddon(addonId)
|
||||||
|
updateAddonAutoUpdateRegistration(addonId)
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
|
||||||
|
message = LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE,
|
||||||
|
)
|
||||||
|
result(Result.success(getManualUpdateAttempt(addonId)))
|
||||||
|
return@fold
|
||||||
|
}
|
||||||
|
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.ERROR,
|
||||||
|
message = throwable.message,
|
||||||
|
)
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = { _, throwable ->
|
||||||
|
saveManualUpdateAttempt(
|
||||||
|
addonId = addonId,
|
||||||
|
status = AddonUpdateStatus.ERROR,
|
||||||
|
message = throwable.message,
|
||||||
|
)
|
||||||
|
result(Result.failure(throwable))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAddonAutoUpdateEnabledForAddonInternal(addonId: String, enabled: Boolean) {
|
||||||
|
val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
|
||||||
|
val changed = if (enabled) {
|
||||||
|
disabledIds.remove(addonId)
|
||||||
|
} else {
|
||||||
|
disabledIds.add(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAddonAutoUpdateRegistration(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAddonAutoUpdatePreference(addonId: String) {
|
||||||
|
val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
|
||||||
|
if (disabledIds.remove(addonId)) {
|
||||||
|
prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markAddonAsLocalFileInstalled(addonId: String) {
|
||||||
|
val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
|
||||||
|
if (localFileAddonIds.add(addonId)) {
|
||||||
|
prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearLocalFileInstalledAddon(addonId: String) {
|
||||||
|
val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
|
||||||
|
if (localFileAddonIds.remove(addonId)) {
|
||||||
|
prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPostponedInstallException(throwable: Throwable): Boolean {
|
||||||
|
val installThrowable = when (throwable) {
|
||||||
|
is WebExtensionInstallException -> throwable.cause
|
||||||
|
else -> throwable.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
return installThrowable is org.mozilla.geckoview.WebExtension.InstallException &&
|
||||||
|
installThrowable.code == ERROR_POSTPONED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAddonAutoUpdateRegistration(addonId: String) {
|
||||||
|
if (
|
||||||
|
isAutoUpdateEnabled() &&
|
||||||
|
isAddonAutoUpdateEnabledForAddon(addonId) &&
|
||||||
|
!isLocalFileInstalledAddon(addonId)
|
||||||
|
) {
|
||||||
|
components.core.addonUpdater.registerForFutureUpdates(addonId)
|
||||||
|
} else {
|
||||||
|
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleManagedAddonUpdate(addonId: String) {
|
||||||
|
val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
|
||||||
|
isAddonAutoUpdateEnabledForAddon(addonId)
|
||||||
|
if (shouldRestorePeriodicRegistration) {
|
||||||
|
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.core.addonUpdater.update(addonId)
|
||||||
|
|
||||||
|
if (shouldRestorePeriodicRegistration) {
|
||||||
|
scope.launch {
|
||||||
|
delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
updateAddonAutoUpdateRegistration(addonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runManagedAddonUpdate(
|
||||||
|
addonId: String,
|
||||||
|
onComplete: (AddonUpdateAttemptInfo?) -> Unit,
|
||||||
|
) {
|
||||||
|
val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
|
||||||
|
isAddonAutoUpdateEnabledForAddon(addonId)
|
||||||
|
if (shouldRestorePeriodicRegistration) {
|
||||||
|
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.core.addonManager.updateAddon(addonId) { status ->
|
||||||
|
val pigeonStatus = status.toPigeon()
|
||||||
|
val message = (status as? AddonUpdater.Status.Error)?.message
|
||||||
|
saveManualUpdateAttempt(addonId, pigeonStatus, message)
|
||||||
|
onComplete(getManualUpdateAttempt(addonId))
|
||||||
|
|
||||||
|
if (shouldRestorePeriodicRegistration) {
|
||||||
|
scope.launch {
|
||||||
|
delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
updateAddonAutoUpdateRegistration(addonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Addon.toPigeon(
|
||||||
|
context: Context,
|
||||||
|
isAutoUpdateEnabled: Boolean,
|
||||||
|
isLocalFileInstalled: Boolean,
|
||||||
|
): AddonInfo {
|
||||||
|
val installedState = installedState
|
||||||
|
val localizedName = displayName(context)
|
||||||
|
val localizedSummary = summary(context)
|
||||||
|
val localizedDescription = if (translatableDescription.isNotEmpty()) {
|
||||||
|
translateDescription(context)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddonInfo(
|
||||||
|
id = id,
|
||||||
|
displayName = localizedName,
|
||||||
|
summary = localizedSummary,
|
||||||
|
description = localizedDescription,
|
||||||
|
downloadUrl = downloadUrl,
|
||||||
|
version = version,
|
||||||
|
installedVersion = installedState?.version,
|
||||||
|
translatedPermissions = translatePermissions(context),
|
||||||
|
translatedRequiredDataCollectionPermissions =
|
||||||
|
translateRequiredDataCollectionPermissions(context),
|
||||||
|
authorName = author?.name,
|
||||||
|
authorUrl = author?.url,
|
||||||
|
homepageUrl = homepageUrl,
|
||||||
|
detailUrl = detailUrl,
|
||||||
|
ratingUrl = ratingUrl,
|
||||||
|
ratingAverage = rating?.average?.toDouble(),
|
||||||
|
ratingReviews = rating?.reviews?.toLong(),
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
icon = provideIcon()?.toWebPBytes(),
|
||||||
|
isInstalled = isInstalled(),
|
||||||
|
isEnabled = isEnabled(),
|
||||||
|
isSupported = isSupported(),
|
||||||
|
isAllowedInPrivateBrowsing = isAllowedInPrivateBrowsing(),
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEnabled,
|
||||||
|
isLocalFileInstalled = isLocalFileInstalled,
|
||||||
|
optionsPageUrl = installedState?.optionsPageUrl,
|
||||||
|
openOptionsPageInTab = installedState?.openOptionsPageInTab ?: false,
|
||||||
|
disabledReason = installedState?.disabledReason?.toPigeon(),
|
||||||
|
incognito = incognito.toPigeon(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Addon.DisabledReason.toPigeon(): AddonDisabledReason {
|
||||||
|
return when (this) {
|
||||||
|
Addon.DisabledReason.UNSUPPORTED -> AddonDisabledReason.UNSUPPORTED
|
||||||
|
Addon.DisabledReason.BLOCKLISTED -> AddonDisabledReason.BLOCKLISTED
|
||||||
|
Addon.DisabledReason.USER_REQUESTED -> AddonDisabledReason.USER_REQUESTED
|
||||||
|
Addon.DisabledReason.NOT_CORRECTLY_SIGNED -> AddonDisabledReason.NOT_CORRECTLY_SIGNED
|
||||||
|
Addon.DisabledReason.INCOMPATIBLE -> AddonDisabledReason.INCOMPATIBLE
|
||||||
|
Addon.DisabledReason.SOFT_BLOCKED -> AddonDisabledReason.SOFT_BLOCKED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Addon.Incognito.toPigeon(): AddonIncognito {
|
||||||
|
return when (this) {
|
||||||
|
Addon.Incognito.SPANNING -> AddonIncognito.SPANNING
|
||||||
|
Addon.Incognito.SPLIT -> AddonIncognito.SPLIT
|
||||||
|
Addon.Incognito.NOT_ALLOWED -> AddonIncognito.NOT_ALLOWED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AddonUpdater.UpdateAttempt.toPigeon(): AddonUpdateAttemptInfo {
|
||||||
|
return AddonUpdateAttemptInfo(
|
||||||
|
addonId = addonId,
|
||||||
|
dateMillisecondsSinceEpoch = date.time,
|
||||||
|
status = status?.toPigeon(),
|
||||||
|
message = (status as? AddonUpdater.Status.Error)?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AddonUpdater.Status.toPigeon(): AddonUpdateStatus {
|
||||||
|
return when (this) {
|
||||||
|
AddonUpdater.Status.NotInstalled -> AddonUpdateStatus.NOT_INSTALLED
|
||||||
|
AddonUpdater.Status.SuccessfullyUpdated -> AddonUpdateStatus.SUCCESSFULLY_UPDATED
|
||||||
|
AddonUpdater.Status.NoUpdateAvailable -> AddonUpdateStatus.NO_UPDATE_AVAILABLE
|
||||||
|
is AddonUpdater.Status.Error -> AddonUpdateStatus.ERROR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -10,6 +10,8 @@ import android.app.Activity
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import eu.weblibre.flutter_mozilla_components.AddonPopupViewFactory
|
||||||
|
import eu.weblibre.flutter_mozilla_components.AddonSettingsViewFactory
|
||||||
import eu.weblibre.flutter_mozilla_components.BrowserFragment
|
import eu.weblibre.flutter_mozilla_components.BrowserFragment
|
||||||
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
|
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
|
||||||
import eu.weblibre.flutter_mozilla_components.EngineProvider
|
import eu.weblibre.flutter_mozilla_components.EngineProvider
|
||||||
@@ -145,6 +147,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
_flutterEvents
|
_flutterEvents
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
|
||||||
|
"eu.weblibre/addon_settings",
|
||||||
|
AddonSettingsViewFactory(activityProvider = { this.activity }),
|
||||||
|
)
|
||||||
|
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
|
||||||
|
"eu.weblibre/addon_popup",
|
||||||
|
AddonPopupViewFactory(activityProvider = { this.activity }),
|
||||||
|
)
|
||||||
isPlatformViewRegistered = true
|
isPlatformViewRegistered = true
|
||||||
|
|
||||||
isGeckoInitialized = false
|
isGeckoInitialized = false
|
||||||
|
|||||||
+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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:theme="@style/Theme.AppCompat.Light"
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:parentActivityName=".MainActivity" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:theme="@style/Theme.AppCompat.Light"
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:label="@string/mozac_feature_addons_addons" />
|
|
||||||
|
|
||||||
<activity android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AppCompat.Light" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AppCompat.Light" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
|
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AppCompat.Light" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
||||||
android:label="@string/mozac_feature_addons_addons"
|
android:label="@string/mozac_feature_addons_addons"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
show
|
show
|
||||||
AddTabParams,
|
AddTabParams,
|
||||||
AddonCollection,
|
AddonCollection,
|
||||||
|
AddonDisabledReason,
|
||||||
|
AddonIncognito,
|
||||||
|
AddonInfo,
|
||||||
|
AddonStoreInfo,
|
||||||
|
AddonUpdateAttemptInfo,
|
||||||
|
AddonUpdateStatus,
|
||||||
AppLinksMode,
|
AppLinksMode,
|
||||||
AudioHitResult,
|
AudioHitResult,
|
||||||
AutoplayStatus,
|
AutoplayStatus,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:rxdart/rxdart.dart';
|
|||||||
|
|
||||||
typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data});
|
typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data});
|
||||||
typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes});
|
typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes});
|
||||||
|
typedef ExtensionPopupEvent = ({String extensionId, String extensionName});
|
||||||
|
|
||||||
final _apiInstance = GeckoAddonsApi();
|
final _apiInstance = GeckoAddonsApi();
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
|
|
||||||
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
|
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
|
||||||
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
|
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
|
||||||
|
final _popupSubject = PublishSubject<ExtensionPopupEvent>();
|
||||||
|
|
||||||
Stream<ExtensionDataEvent> get browserExtensionStream =>
|
Stream<ExtensionDataEvent> get browserExtensionStream =>
|
||||||
_browserExtensionSubject.stream;
|
_browserExtensionSubject.stream;
|
||||||
@@ -33,13 +35,18 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
Stream<ExtensionIconEvent> get browserIconStream =>
|
Stream<ExtensionIconEvent> get browserIconStream =>
|
||||||
_browserIconSubject.stream;
|
_browserIconSubject.stream;
|
||||||
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
|
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
|
||||||
|
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
|
||||||
|
|
||||||
Future<void> startAddonManagerActivity() {
|
Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
|
||||||
return _api.startAddonManagerActivity();
|
return _api.getAddons(allowCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startAddonSettingsActivity(String extensionId) {
|
Future<AddonInfo?> getAddonById(String addonId, {bool allowCache = true}) {
|
||||||
return _api.startAddonSettingsActivity(extensionId);
|
return _api.getAddonById(addonId, allowCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonStoreInfo?> getAddonStoreInfo(String addonId) {
|
||||||
|
return _api.getAddonStoreInfo(addonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> invokeAddonAction(
|
Future<void> invokeAddonAction(
|
||||||
@@ -53,6 +60,52 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
return _api.installAddon(url.toString());
|
return _api.installAddon(url.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AddonInfo> enableAddon(String addonId) {
|
||||||
|
return _api.enableAddon(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonInfo> disableAddon(String addonId) {
|
||||||
|
return _api.disableAddon(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonInfo> setAddonAllowedInPrivateBrowsing(
|
||||||
|
String addonId,
|
||||||
|
bool allowed,
|
||||||
|
) {
|
||||||
|
return _api.setAddonAllowedInPrivateBrowsing(addonId, allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonInfo> setAddonAutoUpdateEnabledForAddon(
|
||||||
|
String addonId,
|
||||||
|
bool enabled,
|
||||||
|
) {
|
||||||
|
return _api.setAddonAutoUpdateEnabledForAddon(addonId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uninstallAddon(String addonId) {
|
||||||
|
return _api.uninstallAddon(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonUpdateAttemptInfo?> triggerAddonUpdate(String addonId) {
|
||||||
|
return _api.triggerAddonUpdate(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> triggerAllAddonUpdates() {
|
||||||
|
return _api.triggerAllAddonUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddonUpdateAttemptInfo?> getLastAddonUpdateAttempt(String addonId) {
|
||||||
|
return _api.getLastAddonUpdateAttempt(addonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isAddonAutoUpdateEnabled() {
|
||||||
|
return _api.isAddonAutoUpdateEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAddonAutoUpdateEnabled({required bool enabled}) {
|
||||||
|
return _api.setAddonAutoUpdateEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRemoveWebExtensionAction(
|
void onRemoveWebExtensionAction(
|
||||||
int sequence,
|
int sequence,
|
||||||
@@ -115,6 +168,11 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWebExtensionPopupRequested(String extensionId, String extensionName) {
|
||||||
|
_popupSubject.add((extensionId: extensionId, extensionName: extensionName));
|
||||||
|
}
|
||||||
|
|
||||||
GeckoAddonService.setUp({
|
GeckoAddonService.setUp({
|
||||||
BinaryMessenger? binaryMessenger,
|
BinaryMessenger? binaryMessenger,
|
||||||
GeckoAddonsApi? api,
|
GeckoAddonsApi? api,
|
||||||
@@ -132,5 +190,6 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
await _pageExtensionSubject.close();
|
await _pageExtensionSubject.close();
|
||||||
await _browserIconSubject.close();
|
await _browserIconSubject.close();
|
||||||
await _pageIconSubject.close();
|
await _pageIconSubject.close();
|
||||||
|
await _popupSubject.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -667,6 +667,112 @@ class WebExtensionData {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AddonDisabledReason {
|
||||||
|
unsupported,
|
||||||
|
blocklisted,
|
||||||
|
userRequested,
|
||||||
|
notCorrectlySigned,
|
||||||
|
incompatible,
|
||||||
|
softBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AddonIncognito { spanning, split, notAllowed }
|
||||||
|
|
||||||
|
enum AddonUpdateStatus {
|
||||||
|
notInstalled,
|
||||||
|
successfullyUpdated,
|
||||||
|
noUpdateAvailable,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonInfo {
|
||||||
|
final String id;
|
||||||
|
final String displayName;
|
||||||
|
final String? summary;
|
||||||
|
final String description;
|
||||||
|
final String downloadUrl;
|
||||||
|
final String version;
|
||||||
|
final String? installedVersion;
|
||||||
|
final List<String> translatedPermissions;
|
||||||
|
final List<String> translatedRequiredDataCollectionPermissions;
|
||||||
|
final String? authorName;
|
||||||
|
final String? authorUrl;
|
||||||
|
final String homepageUrl;
|
||||||
|
final String detailUrl;
|
||||||
|
final String ratingUrl;
|
||||||
|
final double? ratingAverage;
|
||||||
|
final int? ratingReviews;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
final Uint8List? icon;
|
||||||
|
final bool isInstalled;
|
||||||
|
final bool isEnabled;
|
||||||
|
final bool isSupported;
|
||||||
|
final bool isAllowedInPrivateBrowsing;
|
||||||
|
final bool isAutoUpdateEnabled;
|
||||||
|
final bool isLocalFileInstalled;
|
||||||
|
final String? optionsPageUrl;
|
||||||
|
final bool openOptionsPageInTab;
|
||||||
|
final AddonDisabledReason? disabledReason;
|
||||||
|
final AddonIncognito incognito;
|
||||||
|
|
||||||
|
const AddonInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.displayName,
|
||||||
|
this.summary,
|
||||||
|
required this.description,
|
||||||
|
required this.downloadUrl,
|
||||||
|
required this.version,
|
||||||
|
this.installedVersion,
|
||||||
|
required this.translatedPermissions,
|
||||||
|
required this.translatedRequiredDataCollectionPermissions,
|
||||||
|
this.authorName,
|
||||||
|
this.authorUrl,
|
||||||
|
required this.homepageUrl,
|
||||||
|
required this.detailUrl,
|
||||||
|
required this.ratingUrl,
|
||||||
|
this.ratingAverage,
|
||||||
|
this.ratingReviews,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.icon,
|
||||||
|
required this.isInstalled,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.isSupported,
|
||||||
|
required this.isAllowedInPrivateBrowsing,
|
||||||
|
required this.isAutoUpdateEnabled,
|
||||||
|
required this.isLocalFileInstalled,
|
||||||
|
this.optionsPageUrl,
|
||||||
|
required this.openOptionsPageInTab,
|
||||||
|
this.disabledReason,
|
||||||
|
this.incognito = AddonIncognito.spanning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonStoreInfo {
|
||||||
|
final String latestVersion;
|
||||||
|
final String latestXpiUrl;
|
||||||
|
|
||||||
|
const AddonStoreInfo({
|
||||||
|
required this.latestVersion,
|
||||||
|
required this.latestXpiUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonUpdateAttemptInfo {
|
||||||
|
final String addonId;
|
||||||
|
final int dateMillisecondsSinceEpoch;
|
||||||
|
final AddonUpdateStatus? status;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const AddonUpdateAttemptInfo({
|
||||||
|
required this.addonId,
|
||||||
|
required this.dateMillisecondsSinceEpoch,
|
||||||
|
this.status,
|
||||||
|
this.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
enum GeckoSuggestionType { session, clipboard, history }
|
enum GeckoSuggestionType { session, clipboard, history }
|
||||||
|
|
||||||
class GeckoSuggestion {
|
class GeckoSuggestion {
|
||||||
@@ -1738,14 +1844,49 @@ abstract class GeckoSelectionActionEvents {
|
|||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class GeckoAddonsApi {
|
abstract class GeckoAddonsApi {
|
||||||
void startAddonManagerActivity();
|
@async
|
||||||
|
List<AddonInfo> getAddons(bool allowCache);
|
||||||
|
|
||||||
void startAddonSettingsActivity(String extensionId);
|
@async
|
||||||
|
AddonInfo? getAddonById(String addonId, bool allowCache);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonStoreInfo? getAddonStoreInfo(String addonId);
|
||||||
|
|
||||||
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
|
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonInfo enableAddon(String addonId);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonInfo disableAddon(String addonId);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonInfo setAddonAllowedInPrivateBrowsing(String addonId, bool allowed);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonInfo setAddonAutoUpdateEnabledForAddon(String addonId, bool enabled);
|
||||||
|
|
||||||
|
@async
|
||||||
|
void uninstallAddon(String addonId);
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonUpdateAttemptInfo? triggerAddonUpdate(String addonId);
|
||||||
|
|
||||||
|
@async
|
||||||
|
void triggerAllAddonUpdates();
|
||||||
|
|
||||||
|
@async
|
||||||
|
AddonUpdateAttemptInfo? getLastAddonUpdateAttempt(String addonId);
|
||||||
|
|
||||||
@async
|
@async
|
||||||
void installAddon(String url);
|
void installAddon(String url);
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool isAddonAutoUpdateEnabled();
|
||||||
|
|
||||||
|
@async
|
||||||
|
void setAddonAutoUpdateEnabled(bool enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@FlutterApi()
|
@FlutterApi()
|
||||||
@@ -1769,6 +1910,8 @@ abstract class GeckoAddonEvents {
|
|||||||
WebExtensionActionType actionType,
|
WebExtensionActionType actionType,
|
||||||
Uint8List icon,
|
Uint8List icon,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void onWebExtensionPopupRequested(String extensionId, String extensionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
|
|||||||
Reference in New Issue
Block a user