From d2bd36b1ed3cdf2cf5729107360e9a49aa297129 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 30 Apr 2026 10:44:49 +0200 Subject: [PATCH] ublock list management initial --- apps/weblibre/.gitignore | 1 + apps/weblibre/lib/core/routing/routes.dart | 1 + apps/weblibre/lib/core/routing/routes.g.dart | 26 + .../lib/core/routing/routes.settings.dart | 11 + .../presentation/screens/addon_details.dart | 10 + .../services/engine_settings_replication.dart | 31 + .../engine_settings_replication.g.dart | 2 +- .../widgets/browser_menu_sheet.dart | 3 +- .../browser_modules/app_bar_title.dart | 1 + .../widgets/tab_view/tab_preview.dart | 3 +- .../search_modules/bookmark_search.dart | 5 +- .../search_modules/history_suggestions.dart | 5 +- .../widgets/search_modules/tab_search.dart | 2 +- .../tabs/domain/repositories/tab.g.dart | 2 +- .../presentation/pages/ublock_opt_in.dart | 52 + .../screens/advanced_settings.dart | 27 +- .../screens/extensions_settings.dart | 56 +- .../screens/privacy_security_settings.dart | 22 + .../screens/ublock_filter_lists.dart | 1105 +++++++++++++++++ .../widgets/small_web_history_list.dart | 1 + .../user/data/models/engine_settings.dart | 28 +- .../user/data/models/engine_settings.g.dart | 23 + .../user/data/models/ublock_asset.dart | 275 ++++ .../user/data/models/ublock_asset.g.dart | 68 + .../models/ublock_filter_list_settings.dart | 177 +++ .../models/ublock_filter_list_settings.g.dart | 241 ++++ .../user/data/providers/ublock_assets.dart | 35 + .../user/data/providers/ublock_assets.g.dart | 52 + .../domain/repositories/engine_settings.dart | 4 + .../repositories/engine_settings.g.dart | 2 +- apps/weblibre/lib/main.dart | 28 + .../presentation/widgets/uri_breadcrumb.dart | 18 + .../presentation/widgets/url_list_tile.dart | 3 + apps/weblibre/pubspec.yaml | 1 + .../ublock_filter_list_settings_test.dart | 411 ++++++ .../widgets/uri_breadcrumb_test.dart | 143 +++ .../GlobalComponents.kt | 42 + .../api/GeckoBrowserApiImpl.kt | 5 + .../pigeons/Gecko.g.kt | 6 +- .../src/domain/services/gecko_browser.dart | 4 + .../lib/src/pigeons/gecko.g.dart | 4 + .../pigeons/gecko.dart | 9 + scripts/update-assets.sh | 14 +- 43 files changed, 2894 insertions(+), 65 deletions(-) create mode 100644 apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart create mode 100644 apps/weblibre/lib/features/user/data/models/ublock_asset.dart create mode 100644 apps/weblibre/lib/features/user/data/models/ublock_asset.g.dart create mode 100644 apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.dart create mode 100644 apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.g.dart create mode 100644 apps/weblibre/lib/features/user/data/providers/ublock_assets.dart create mode 100644 apps/weblibre/lib/features/user/data/providers/ublock_assets.g.dart create mode 100644 apps/weblibre/test/features/user/data/models/ublock_filter_list_settings_test.dart create mode 100644 apps/weblibre/test/presentation/widgets/uri_breadcrumb_test.dart diff --git a/apps/weblibre/.gitignore b/apps/weblibre/.gitignore index 7b16fc47..8fd9764b 100644 --- a/apps/weblibre/.gitignore +++ b/apps/weblibre/.gitignore @@ -35,6 +35,7 @@ assets/bangs/ assets/preferences/builtin-bridges.json assets/preferences/url-shortener-list.json assets/preferences/url_cleaner_data.minify.json +/assets/ublock/ # Android related **/android/**/gradle-wrapper.jar diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 8bc0d609..693c7b4a 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -78,6 +78,7 @@ import 'package:weblibre/features/settings/presentation/screens/search_settings. import 'package:weblibre/features/settings/presentation/screens/settings.dart'; import 'package:weblibre/features/settings/presentation/screens/toolbar_layout_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart'; +import 'package:weblibre/features/settings/presentation/screens/ublock_filter_lists.dart'; import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index 5f185307..a841ee94 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -1615,6 +1615,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'AddonCollectionRoute', factory: $AddonCollectionRoute._fromState, ), + GoRouteData.$route( + path: 'ublock_filter_lists', + name: 'UBlockFilterListsRoute', + factory: $UBlockFilterListsRoute._fromState, + ), GoRouteData.$route( path: 'tracking_protection_exceptions', name: 'TrackingProtectionExceptionsRoute', @@ -2013,6 +2018,27 @@ mixin $AddonCollectionRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $UBlockFilterListsRoute on GoRouteData { + static UBlockFilterListsRoute _fromState(GoRouterState state) => + UBlockFilterListsRoute(); + + @override + String get location => GoRouteData.$location('/settings/ublock_filter_lists'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $TrackingProtectionExceptionsRoute on GoRouteData { static TrackingProtectionExceptionsRoute _fromState(GoRouterState state) => TrackingProtectionExceptionsRoute(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index 8c7eb7b7..e0bcb583 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -83,6 +83,10 @@ part of 'routes.dart'; name: 'AddonCollectionRoute', path: 'addon_collection', ), + TypedGoRoute( + name: 'UBlockFilterListsRoute', + path: 'ublock_filter_lists', + ), TypedGoRoute( name: 'TrackingProtectionExceptionsRoute', path: 'tracking_protection_exceptions', @@ -218,6 +222,13 @@ class AddonCollectionRoute extends GoRouteData with $AddonCollectionRoute { } } +class UBlockFilterListsRoute extends GoRouteData with $UBlockFilterListsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const UBlockFilterListsScreen(); + } +} + class WebEngineHardeningRoute extends GoRouteData with $WebEngineHardeningRoute { @override diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart index a85615c1..647a2bdc 100644 --- a/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart +++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart @@ -338,6 +338,16 @@ class _ManagementSection extends ConsumerWidget { trailing: const Icon(Icons.chevron_right), onTap: () => openAddonSettingsFlow(context, ref, addon), ), + if (addon.id == 'uBlock0@raymondhill.net') + ListTile( + leading: const Icon(Icons.filter_list), + title: const Text('Filter Lists & Hardenings'), + subtitle: const Text( + 'Manage filter lists and apply WebLibre hardenings', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => UBlockFilterListsRoute().push(context), + ), ListTile( leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Permissions'), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart index 3dc34acc..22f7cf9a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart @@ -17,6 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart' show ThemeMode; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -26,6 +28,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -33,6 +36,7 @@ part 'engine_settings_replication.g.dart'; const _safeBrowsingMalwarePref = 'browser.safebrowsing.malware.enabled'; const _safeBrowsingPhishingPref = 'browser.safebrowsing.phishing.enabled'; +const ublockFilterListsPref = 'browser.weblibre.uBO.filterLists'; /// Checks if any Custom ETP setting changed between two EngineSettings instances. bool _customEtpSettingsChanged( @@ -55,6 +59,22 @@ bool _customEtpSettingsChanged( previous.allowListConvenience != current.allowListConvenience; } +Future syncUBlockFilterLists( + PreferenceFixator fixator, + EngineSettings settings, +) async { + if (!settings.ublockFilterListSettings.enabled) { + await fixator.unregister(ublockFilterListsPref); + await GeckoPrefService().resetPrefs([ublockFilterListsPref]); + return; + } + + await fixator.register( + ublockFilterListsPref, + jsonEncode(settings.ublockFilterListSettings.resolveFinalList()), + ); +} + @Riverpod(keepAlive: true) class EngineSettingsReplicationService extends _$EngineSettingsReplicationService { @@ -296,6 +316,13 @@ class EngineSettingsReplicationService if (previous.value?.lnaEnabled != settings.lnaEnabled) { await _service.lnaEnabled(settings.lnaEnabled); } + if (previous.value?.ublockFilterListSettings != + settings.ublockFilterListSettings) { + await syncUBlockFilterLists( + ref.read(preferenceFixatorProvider.notifier), + settings, + ); + } } else { await _service.setDefaultSettings(settings); await ref @@ -319,6 +346,10 @@ class EngineSettingsReplicationService await ref .read(preferenceFixatorProvider.notifier) .register('intl.accept_languages', settings.locales.join(',')); + await syncUBlockFilterLists( + ref.read(preferenceFixatorProvider.notifier), + settings, + ); // Initialize unsigned extensions fixator from Gecko pref await ref.read(allowUnsignedExtensionsProvider.future); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart index 3b20dbe7..87a626cc 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart @@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider } String _$engineSettingsReplicationServiceHash() => - r'061f0b32a9c49e3bd4b76cd99993620558e0d3b2'; + r'71b611a99af83187e392d16288cec280ef44269f'; abstract class _$EngineSettingsReplicationService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index 605f3d20..a62e4e1d 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -36,7 +36,6 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/providers/persisted_bool.dart'; import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; @@ -1715,7 +1714,7 @@ class _ExtensionsCard extends HookConsumerWidget { final rootContext = Navigator.of(context, rootNavigator: true).context; Future openExtensionSettings(String extensionId) async { Navigator.pop(context); - await openAddonSettingsFlowById(rootContext, ref, extensionId); + await AddonDetailsRoute(addonId: extensionId).push(rootContext); } return _buildMenuCard( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index f52b81ad..4384f2fa 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -169,6 +169,7 @@ class CompactAppBarTitleView extends StatelessWidget { Flexible( child: UriBreadcrumb( uri: tabState.url, + showHttpScheme: false, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index f208508c..632f2c8e 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -546,6 +546,7 @@ class ListTabPreview extends HookConsumerWidget { Expanded( child: UriBreadcrumb( uri: tabState.url, + showHttpScheme: false, style: textTheme.bodySmall?.copyWith( color: subtitleColor, ), @@ -636,7 +637,7 @@ class SyncedListTabPreview extends StatelessWidget { ), ], ), - UriBreadcrumb(uri: url), + UriBreadcrumb(uri: url, showHttpScheme: false), ], ), trailing: const Icon(Icons.open_in_new), diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart index 06ce6f0a..76422a0a 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart @@ -77,7 +77,10 @@ class BookmarkSearch extends HookConsumerWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - subtitle: UriBreadcrumb(uri: bookmark.url), + subtitle: UriBreadcrumb( + uri: bookmark.url, + showHttpScheme: false, + ), onTap: () => onUriSelected(bookmark.url), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index fd329269..38e91576 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -112,7 +112,10 @@ class HistorySuggestions extends HookConsumerWidget { ), subtitle: uri.mapNotNull( - (uri) => UriBreadcrumb(uri: uri), + (uri) => UriBreadcrumb( + uri: uri, + showHttpScheme: false, + ), ) ?? suggestion.description.mapNotNull( (description) => Text( diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index e037133a..bd721833 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -244,7 +244,7 @@ class TabSearch extends HookConsumerWidget { maxLines: 3, overflow: TextOverflow.ellipsis, ) - : UriBreadcrumb(uri: result.url), + : UriBreadcrumb(uri: result.url, showHttpScheme: false), onTap: () async { await ref .read(tabRepositoryProvider.notifier) diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 0a243827..39dc6ca4 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'c64a44c7d9de566617798a0a3bdcad51bf436658'; +String _$tabDataRepositoryHash() => r'e6482b5408aa567f626d2f0a9455b2cc908ba84f'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/onboarding/presentation/pages/ublock_opt_in.dart b/apps/weblibre/lib/features/onboarding/presentation/pages/ublock_opt_in.dart index d0ce0043..dbfa666a 100644 --- a/apps/weblibre/lib/features/onboarding/presentation/pages/ublock_opt_in.dart +++ b/apps/weblibre/lib/features/onboarding/presentation/pages/ublock_opt_in.dart @@ -20,11 +20,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart'; +import 'package:weblibre/features/user/data/providers/ublock_assets.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/presentation/widgets/browser_page.dart'; class UBlockOptInPage extends HookConsumerWidget implements IFormPage { @@ -36,6 +42,7 @@ class UBlockOptInPage extends HookConsumerWidget implements IFormPage { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final installUBlock = useState(true); return BrowserPage( child: BrowserPageContent( @@ -66,6 +73,7 @@ There are many other lists available to block even more. FormField( initialValue: true, onSaved: (newValue) { + installUBlock.value = newValue ?? false; if (newValue == true) { unawaited( ref @@ -80,9 +88,53 @@ There are many other lists available to block even more. title: const Text('Install uBlock Origin Extension'), onChanged: (value) { field.didChange(value); + installUBlock.value = value; }, ), ), + FormField( + initialValue: true, + onSaved: (newValue) { + if (newValue != true || !installUBlock.value) return; + + Future applyOptimizedDefaults() async { + try { + final registry = await ref.read( + ublockAssetsRegistryProvider.future, + ); + final optimized = + UBlockFilterListSettings.optimizedDefaults(registry); + await ref + .read(engineSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith + .ublockFilterListSettings(optimized), + ); + } catch (e, s) { + logger.w( + 'Failed applying optimized uBlock defaults during onboarding', + error: e, + stackTrace: s, + ); + } + } + + unawaited(applyOptimizedDefaults()); + }, + builder: (field) => SwitchListTile( + contentPadding: EdgeInsets.zero, + value: field.value ?? false, + title: const Text('Apply optimized defaults'), + subtitle: const Text( + 'Enable WebLibre hardening filter lists.', + ), + onChanged: installUBlock.value + ? (value) { + field.didChange(value); + } + : null, + ), + ), ], ), ), diff --git a/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart index f1dfeb93..ad9bcc68 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart @@ -292,24 +292,17 @@ class _ErrorLogsTile extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomListTile( - title: 'Error Logs', - subtitle: 'View and copy logs for issue reporting', - prefix: Padding( - padding: const EdgeInsets.only(right: 16.0), - child: Icon( - Icons.bug_report, - size: 24, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - suffix: FilledButton.icon( - onPressed: () async { - await ErrorLogsRoute().push(context); - }, - icon: const Icon(Icons.open_in_new), - label: const Text('View'), + return ListTile( + leading: Icon( + Icons.bug_report, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + title: const Text('Error Logs'), + subtitle: const Text('View and copy logs for issue reporting'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await ErrorLogsRoute().push(context); + }, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart index 9af975c1..89ace8f6 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart @@ -65,25 +65,18 @@ class _ManageExtensionsTile extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomListTile( - title: 'Manage Extensions', + return ListTile( + leading: Icon( + MdiIcons.puzzleEdit, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: const Text('Manage Extensions'), subtitle: - 'Browse installed, disabled, available, and unsupported extensions', - prefix: Padding( - padding: const EdgeInsets.only(right: 16.0), - child: Icon( - MdiIcons.puzzleEdit, - size: 24, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - suffix: FilledButton.icon( - onPressed: () async { - await const AddonManagerRoute().push(context); - }, - icon: const Icon(Icons.open_in_new), - label: const Text('Open'), - ), + const Text('Browse installed, disabled, available, and unsupported extensions'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await const AddonManagerRoute().push(context); + }, ); } } @@ -93,24 +86,17 @@ class _AddonCollectionTile extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomListTile( - title: 'Custom Collection', - subtitle: 'Use a custom Mozilla addon collection', - prefix: Padding( - padding: const EdgeInsets.only(right: 16.0), - child: Icon( - MdiIcons.folderMultiple, - size: 24, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - suffix: FilledButton.icon( - onPressed: () async { - await AddonCollectionRoute().push(context); - }, - icon: const Icon(Icons.settings), - label: const Text('Configure'), + return ListTile( + leading: Icon( + MdiIcons.folderMultiple, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + title: const Text('Custom Collection'), + subtitle: const Text('Use a custom Mozilla addon collection'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await AddonCollectionRoute().push(context); + }, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart index 94d434a0..80ebd1cc 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart @@ -97,6 +97,7 @@ class _TrackingProtectionSection extends StatelessWidget { _BounceTrackingProtectionTile(), _QueryParameterStrippingSection(), _TrackingProtectionExceptionsTile(), + _UBlockFilterListsTile(), ], ); } @@ -727,6 +728,27 @@ class _WebEngineHardeningTile extends StatelessWidget { } } +class _UBlockFilterListsTile extends StatelessWidget { + const _UBlockFilterListsTile(); + + @override + Widget build(BuildContext context) { + return ListTile( + title: const Text('uBlock Filter Lists & Hardenings'), + subtitle: const Text('Manage filter lists and apply WebLibre hardenings'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + leading: const Icon(Icons.filter_list), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await UBlockFilterListsRoute().push(context); + }, + ); + } +} + class _FissionEnabledTile extends HookConsumerWidget { const _FissionEnabledTile(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart b/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart new file mode 100644 index 00000000..dec0c9ee --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart @@ -0,0 +1,1105 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/data/models/ublock_asset.dart'; +import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart'; +import 'package:weblibre/features/user/data/providers/ublock_assets.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/utils/form_validators.dart'; + +typedef _SettingsMutator = + Future Function( + UBlockFilterListSettings Function(UBlockFilterListSettings current) + mutator, + ); + +class UBlockFilterListsScreen extends HookConsumerWidget { + const UBlockFilterListsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch( + engineSettingsWithDefaultsProvider.select( + (value) => value.ublockFilterListSettings, + ), + ); + + final registryAsync = ref.watch(ublockAssetsRegistryProvider); + final registryReady = registryAsync.hasValue; + + Future updateSettingsImpl( + UBlockFilterListSettings Function(UBlockFilterListSettings current) + mutator, + ) async { + await ref + .read(engineSettingsRepositoryProvider.notifier) + .updateSettings( + (currentSettings) => + currentSettings.copyWith.ublockFilterListSettings( + mutator(currentSettings.ublockFilterListSettings), + ), + ); + } + + // Sync regional auto-tokens when registry becomes available so that an + // auto-select switch displayed as "on" actually reflects in the enabled + // lists. Without this fix-up, users would have to toggle the switch + // off and on for the regional tokens to take effect. + useEffect( + () { + if (!registryReady) return null; + if (!settings.enabled) return null; + if (!settings.autoSelectRegionalLists) return null; + final registry = registryAsync.value!; + final langCodes = WidgetsBinding.instance.platformDispatcher.locales + .map((l) => l.toLanguageTag()) + .toList(); + final expected = registry.tokensMatchingLocales(langCodes); + final current = settings.autoEnabledStockListTokens; + final same = + expected.length == current.length && + expected.toSet().containsAll(current); + if (!same) { + unawaited( + Future.microtask( + () => updateSettingsImpl( + (c) => c.copyWith.autoEnabledStockListTokens(expected), + ), + ), + ); + } + return null; + }, + [ + registryReady, + settings.enabled, + settings.autoSelectRegionalLists, + settings.autoEnabledStockListTokens, + ], + ); + + final updateSettings = updateSettingsImpl; + + Future enableManagement(UBlockFilterListSettings current) async { + final registry = registryAsync.value!; + + if (!current.enabled && + current.enabledStockListTokens.isEmpty && + current.autoEnabledStockListTokens.isEmpty && + current.externalFilterLists.isEmpty) { + await updateSettings( + (_) => UBlockFilterListSettings.managedDefaults(registry), + ); + } else { + await updateSettings((c) => c.copyWith.enabled(true)); + } + } + + Future resetToDefaults() async { + final registry = registryAsync.value!; + await updateSettings( + (_) => UBlockFilterListSettings.managedDefaults(registry), + ); + } + + Future applyWebLibreHardenings() async { + await updateSettings((current) { + final tokens = [...current.enabledStockListTokens]; + for (final token in kUBlockHardeningStockTokens) { + if (!tokens.contains(token) && + !current.autoEnabledStockListTokens.contains(token)) { + tokens.add(token); + } + } + final externals = [...current.externalFilterLists]; + final existingUrls = externals.map((e) => e.url).toSet(); + for (final entry in kUBlockHardeningExternalLists) { + if (!existingUrls.contains(entry.url) && + externals.length < kUBlockMaxExternalUrls) { + externals.add(entry); + existingUrls.add(entry.url); + } + } + return current.copyWith( + enabledStockListTokens: tokens, + externalFilterLists: externals, + ); + }); + } + + Future enableAutoSelect() async { + final registry = registryAsync.value!; + final langCodes = WidgetsBinding.instance.platformDispatcher.locales + .map((l) => l.toLanguageTag()) + .toList(); + final autoTokens = registry.tokensMatchingLocales(langCodes); + + await updateSettings( + (c) => c.copyWith( + autoSelectRegionalLists: true, + autoEnabledStockListTokens: autoTokens, + ), + ); + } + + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar(title: const Text('uBlock Filter Lists')), + body: SafeArea( + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + const _SectionHeader(label: 'Management'), + _ManagementCard( + settings: settings, + registryReady: registryReady, + onToggleManagement: (value) async { + if (!value) { + await updateSettings((c) => c.copyWith.enabled(false)); + return; + } + await enableManagement(settings); + }, + onToggleAutoSelect: (value) async { + if (value) { + await enableAutoSelect(); + } else { + await updateSettings( + (c) => c.copyWith( + autoSelectRegionalLists: false, + autoEnabledStockListTokens: [], + ), + ); + } + }, + ), + if (settings.enabled) ...[ + const SizedBox(height: 24), + const _SectionHeader(label: 'Quick Actions'), + _QuickActionsCard( + enabled: registryReady, + onResetDefaults: () => _confirmAndRun( + context, + title: 'Reset to defaults?', + message: + 'This will restore uBlock Origin to its default ' + 'filter list configuration and remove any external ' + 'lists you added.', + confirmLabel: 'Reset', + action: resetToDefaults, + ), + onApplyHardenings: () => _confirmAndRun( + context, + title: 'Apply WebLibre Hardenings?', + message: + 'This will enable a curated set of additional ' + 'filter lists and add a legitimate URL shortener ' + 'list as an external list.', + confirmLabel: 'Apply', + action: applyWebLibreHardenings, + ), + ), + ], + const SizedBox(height: 24), + const _SectionHeader(label: 'Filter Lists'), + const _InfoBanner( + message: + 'Changes to uBlock Origin filter lists require an ' + 'app restart to take effect. Due to caching, ' + 'some changes may need a few minutes and an ' + 'additional restart to fully apply.', + ), + registryAsync.when( + data: (registry) => _FilterListGroups( + registry: registry, + settings: settings, + onUpdate: updateSettings, + ), + loading: () => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('Failed to load filter list assets: $error'), + ), + ), + const SizedBox(height: 24), + const _SectionHeader(label: 'External Lists'), + _ExternalListsCard( + settings: settings, + onUpdate: updateSettings, + ), + const SizedBox(height: 32), + ], + ); + }, + ), + ), + ); + } +} + +Future _confirmAndRun( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + required Future Function() action, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ), + ); + if (confirmed == true) { + await action(); + } +} + +class _QuickActionsCard extends StatelessWidget { + final bool enabled; + final VoidCallback onResetDefaults; + final VoidCallback onApplyHardenings; + + const _QuickActionsCard({ + required this.enabled, + required this.onResetDefaults, + required this.onApplyHardenings, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.restart_alt), + title: const Text('Reset to defaults'), + subtitle: const Text( + "Restore uBlock Origin's default filter list configuration.", + ), + trailing: const Icon(Icons.chevron_right), + onTap: enabled ? onResetDefaults : null, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ListTile( + leading: const Icon(Icons.shield_outlined), + title: const Text('Apply WebLibre Hardenings'), + subtitle: const Text( + 'Enable a curated set of additional filter lists.', + ), + trailing: const Icon(Icons.chevron_right), + onTap: enabled ? onApplyHardenings : null, + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String label; + + const _SectionHeader({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8, top: 8), + child: Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +class _InfoBanner extends StatelessWidget { + final String message; + + const _InfoBanner({required this.message}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: colorScheme.onSecondaryContainer), + const SizedBox(width: 16), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ); + } +} + +class _ManagementCard extends StatelessWidget { + final UBlockFilterListSettings settings; + final bool registryReady; + final ValueChanged onToggleManagement; + final ValueChanged onToggleAutoSelect; + + const _ManagementCard({ + required this.settings, + required this.registryReady, + required this.onToggleManagement, + required this.onToggleAutoSelect, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final showAutoSelect = settings.enabled; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Column( + children: [ + SwitchListTile.adaptive( + title: const Text('Manage with WebLibre'), + subtitle: const Text( + "WebLibre controls uBlock Origin's enabled filter lists on " + 'next browser start.', + ), + value: settings.enabled, + onChanged: !registryReady ? null : onToggleManagement, + ), + if (!settings.enabled) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Enabling management starts from uBO's common baseline " + 'lists and preserves My filters.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (showAutoSelect) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + SwitchListTile.adaptive( + title: const Text('Auto-select languages'), + subtitle: const Text( + 'Enable regional filter lists matching your device languages.', + ), + value: settings.autoSelectRegionalLists, + onChanged: !registryReady ? null : onToggleAutoSelect, + ), + ], + ], + ), + ); + } +} + +class _FilterListGroups extends StatelessWidget { + final UBlockAssetsRegistry registry; + final UBlockFilterListSettings settings; + final _SettingsMutator onUpdate; + + const _FilterListGroups({ + required this.registry, + required this.settings, + required this.onUpdate, + }); + + @override + Widget build(BuildContext context) { + final groupedTree = registry.buildGroupedParentTree(); + final enabledTokens = { + ...settings.enabledStockListTokens, + ...settings.autoEnabledStockListTokens, + }; + final autoTokens = settings.autoEnabledStockListTokens.toSet(); + + Future toggle(String token, bool value) async { + await onUpdate((current) { + if (value) { + final manual = [...current.enabledStockListTokens, token]; + return current.copyWith.enabledStockListTokens(manual); + } + final manual = [...current.enabledStockListTokens]..remove(token); + final auto = [...current.autoEnabledStockListTokens]..remove(token); + return current.copyWith( + enabledStockListTokens: manual, + autoEnabledStockListTokens: auto, + ); + }); + } + + return Column( + children: [ + for (final group in UBlockAssetGroup.displayOrder) + if (groupedTree.containsKey(group)) + _GroupCard( + group: group, + parentTree: groupedTree[group]!, + registry: registry, + enabledTokens: enabledTokens, + autoTokens: autoTokens, + managementEnabled: settings.enabled, + onToggle: toggle, + ), + ], + ); + } +} + +class _CountPill extends StatelessWidget { + final int enabled; + final int total; + final bool primary; + + const _CountPill({ + required this.enabled, + required this.total, + required this.primary, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: primary + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$enabled/$total', + style: TextStyle( + color: primary + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } +} + +class _GroupCard extends StatelessWidget { + final UBlockAssetGroup group; + final Map> parentTree; + final UBlockAssetsRegistry registry; + final Set enabledTokens; + final Set autoTokens; + final bool managementEnabled; + final Future Function(String token, bool value) onToggle; + + const _GroupCard({ + required this.group, + required this.parentTree, + required this.registry, + required this.enabledTokens, + required this.autoTokens, + required this.managementEnabled, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final allTokens = parentTree.values.expand((l) => l).toList(); + final totalCount = allTokens.length; + final enabledCount = allTokens.where(enabledTokens.contains).length; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + clipBehavior: Clip.antiAlias, + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: group == UBlockAssetGroup.$default, + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + group.label, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + trailing: _CountPill( + enabled: enabledCount, + total: totalCount, + primary: true, + ), + children: [ + ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Column( + children: [ + for (final entry in parentTree.entries) + if (entry.key != null) + _SubGroupTile( + parentTitle: entry.key!, + tokenKeys: entry.value, + registry: registry, + enabledTokens: enabledTokens, + autoTokens: autoTokens, + managementEnabled: managementEnabled, + onToggle: onToggle, + depth: 1, + ) + else + for (final tokenKey in entry.value) + _FilterListTile( + tokenKey: tokenKey, + entry: registry[tokenKey]!, + isEnabled: enabledTokens.contains(tokenKey), + isAuto: autoTokens.contains(tokenKey), + managementEnabled: managementEnabled, + onToggle: onToggle, + depth: 1, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SubGroupTile extends StatelessWidget { + final String parentTitle; + final List tokenKeys; + final UBlockAssetsRegistry registry; + final Set enabledTokens; + final Set autoTokens; + final bool managementEnabled; + final int depth; + final Future Function(String token, bool value) onToggle; + + const _SubGroupTile({ + required this.parentTitle, + required this.tokenKeys, + required this.registry, + required this.enabledTokens, + required this.autoTokens, + required this.managementEnabled, + required this.onToggle, + required this.depth, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final enabledCount = tokenKeys.where(enabledTokens.contains).length; + final indent = 16.0 + (depth * 12.0); + + return Column( + children: [ + Divider( + height: 1, + indent: indent, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ColoredBox( + color: colorScheme.surfaceContainer, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: EdgeInsets.only(left: indent, right: 16), + title: Text( + parentTitle, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + trailing: _CountPill( + enabled: enabledCount, + total: tokenKeys.length, + primary: false, + ), + children: [ + for (final tokenKey in tokenKeys) + _FilterListTile( + tokenKey: tokenKey, + entry: registry[tokenKey]!, + isEnabled: enabledTokens.contains(tokenKey), + isAuto: autoTokens.contains(tokenKey), + managementEnabled: managementEnabled, + onToggle: onToggle, + depth: depth + 1, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _FilterListTile extends StatelessWidget { + final String tokenKey; + final UBlockAssetEntry entry; + final bool isEnabled; + final bool isAuto; + final bool managementEnabled; + final int depth; + final Future Function(String token, bool value) onToggle; + + const _FilterListTile({ + required this.tokenKey, + required this.entry, + required this.isEnabled, + required this.isAuto, + required this.managementEnabled, + required this.onToggle, + required this.depth, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final supportUrl = entry.supportURL; + final hasLink = supportUrl != null; + final indent = 16.0 + (depth * 12.0); + + return Column( + children: [ + if (depth > 1) + Divider( + height: 1, + indent: indent, + color: colorScheme.outlineVariant.withValues(alpha: 0.2), + ), + ListTile( + contentPadding: EdgeInsets.only(left: indent, right: 8), + title: Row( + children: [ + Expanded( + child: Text( + entry.title ?? tokenKey, + style: const TextStyle(fontSize: 14), + ), + ), + if (isAuto) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Tooltip( + message: 'Auto-selected for your language', + child: Icon( + Icons.language, + size: 16, + color: colorScheme.primary, + ), + ), + ), + if (entry.isDefaultEnabled && !isEnabled) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Tooltip( + message: 'Default on', + child: Icon( + Icons.recommend_outlined, + size: 16, + color: colorScheme.outline, + ), + ), + ), + ], + ), + subtitle: entry.tags != null + ? Text( + entry.tags!, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasLink) + IconButton( + icon: const Icon(Icons.open_in_new, size: 18), + color: colorScheme.onSurfaceVariant, + tooltip: 'Visit support page', + visualDensity: VisualDensity.compact, + onPressed: () => launchUrl(Uri.parse(supportUrl)), + ), + Switch.adaptive( + value: isEnabled, + onChanged: managementEnabled + ? (value) => onToggle(tokenKey, value) + : null, + ), + ], + ), + ), + ], + ); + } +} + +class _ExternalListsCard extends StatelessWidget { + final UBlockFilterListSettings settings; + final _SettingsMutator onUpdate; + + const _ExternalListsCard({required this.settings, required this.onUpdate}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final lists = settings.externalFilterLists; + final canAdd = settings.enabled && lists.length < kUBlockMaxExternalUrls; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Raw URLs are forwarded to uBlock Origin as external lists. ' + 'Descriptions are only shown here in WebLibre.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (lists.isEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text('No external lists configured.'), + ), + ) + else + for (var i = 0; i < lists.length; i++) ...[ + if (i > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ), + _ExternalListRow( + index: i, + entry: lists[i], + allEntries: lists, + enabled: settings.enabled, + onUpdate: onUpdate, + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + icon: const Icon(Icons.add), + label: const Text('Add external list'), + onPressed: !canAdd + ? null + : () async { + final result = await _promptForExternalList( + context, + existing: lists, + ); + if (result == null) return; + await onUpdate((current) { + final updated = [ + ...current.externalFilterLists, + result, + ]; + return current.copyWith.externalFilterLists(updated); + }); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _ExternalListRow extends StatelessWidget { + final int index; + final UBlockExternalList entry; + final List allEntries; + final bool enabled; + final _SettingsMutator onUpdate; + + const _ExternalListRow({ + required this.index, + required this.entry, + required this.allEntries, + required this.enabled, + required this.onUpdate, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasDescription = + entry.description != null && entry.description!.trim().isNotEmpty; + final uri = Uri.tryParse(entry.url); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hasDescription ? entry.description!.trim() : entry.url, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (hasDescription && uri != null) ...[ + const SizedBox(height: 4), + UriBreadcrumb( + uri: uri, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + color: colorScheme.onSurfaceVariant, + tooltip: 'Edit', + visualDensity: VisualDensity.compact, + onPressed: enabled + ? () async { + final result = await _promptForExternalList( + context, + existing: allEntries, + initial: entry, + ); + if (result == null) return; + await onUpdate((current) { + final updated = [...current.externalFilterLists]; + updated[index] = result; + return current.copyWith.externalFilterLists(updated); + }); + } + : null, + ), + IconButton( + icon: const Icon(Icons.delete_outline), + color: colorScheme.onSurfaceVariant, + tooltip: 'Remove', + visualDensity: VisualDensity.compact, + onPressed: enabled + ? () async { + await onUpdate((current) { + final updated = [...current.externalFilterLists] + ..removeAt(index); + return current.copyWith.externalFilterLists(updated); + }); + } + : null, + ), + ], + ), + ); + } +} + +Future _promptForExternalList( + BuildContext context, { + required List existing, + UBlockExternalList? initial, +}) { + return showDialog( + context: context, + builder: (context) => + _ExternalListDialog(existing: existing, initial: initial), + ); +} + +class _ExternalListDialog extends HookWidget { + final List existing; + final UBlockExternalList? initial; + + const _ExternalListDialog({required this.existing, this.initial}); + + @override + Widget build(BuildContext context) { + final formKey = useMemoized(() => GlobalKey()); + final urlController = useTextEditingController(text: initial?.url ?? ''); + final descriptionController = useTextEditingController( + text: initial?.description ?? '', + ); + final isEdit = initial != null; + + return AlertDialog( + title: Text( + isEdit ? 'Edit external filter list' : 'Add external filter list', + ), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: urlController, + autofocus: !isEdit, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'List URL', + hintText: 'https://example.com/list.txt', + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + final urlError = validateUrl( + trimmed, + onlyHttpProtocol: true, + eagerParsing: false, + ); + if (urlError != null) return urlError; + final clash = existing.any( + (e) => e.url == trimmed && e.url != initial?.url, + ); + if (clash) { + return 'Already added'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: descriptionController, + autofocus: isEdit, + maxLength: 80, + decoration: const InputDecoration( + labelText: 'Description (optional)', + hintText: 'e.g. Annoyances β€” myAuthor', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() == true) { + final desc = descriptionController.text.trim(); + Navigator.of(context).pop( + UBlockExternalList( + url: urlController.text.trim(), + description: desc.isEmpty ? null : desc, + ), + ); + } + }, + child: Text(isEdit ? 'Save' : 'Add'), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart index adccca58..0d293a08 100644 --- a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart +++ b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart @@ -279,6 +279,7 @@ class _HistoryListItem extends ConsumerWidget { const SizedBox(height: 3), UriBreadcrumb( uri: visit.url, + showHttpScheme: false, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/apps/weblibre/lib/features/user/data/models/engine_settings.dart b/apps/weblibre/lib/features/user/data/models/engine_settings.dart index e3aa201e..58257232 100644 --- a/apps/weblibre/lib/features/user/data/models/engine_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/engine_settings.dart @@ -25,6 +25,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart'; import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart'; part 'engine_settings.g.dart'; @@ -133,6 +134,12 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { @JsonKey(fromJson: _addonCollectionFromJson, toJson: _addonCollectionToJson) final AddonCollection? addonCollection; + @JsonKey( + fromJson: _ublockFilterListSettingsFromJson, + toJson: _ublockFilterListSettingsToJson, + ) + final UBlockFilterListSettings ublockFilterListSettings; + final DohSettingsMode dohSettingsMode; final String dohProviderUrl; final String dohDefaultProviderUrl; @@ -179,6 +186,7 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { required this.queryParameterStripping, required this.bounceTrackingProtectionMode, required this.addonCollection, + required this.ublockFilterListSettings, required this.dohSettingsMode, required this.dohProviderUrl, required this.dohDefaultProviderUrl, @@ -232,6 +240,7 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { super.userAgent, bool? enterpriseRootsEnabled, this.addonCollection, + UBlockFilterListSettings? ublockFilterListSettings, DohSettingsMode? dohSettingsMode, String? dohProviderUrl, String? dohDefaultProviderUrl, @@ -267,7 +276,9 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { super.lnaBlocking, bool? lnaBlockTrackers, bool? lnaEnabled, - }) : queryParameterStripping = + }) : ublockFilterListSettings = + ublockFilterListSettings ?? UBlockFilterListSettings(), + queryParameterStripping = queryParameterStripping ?? QueryParameterStripping.enabled, bounceTrackingProtectionMode = bounceTrackingProtectionMode ?? BounceTrackingProtectionMode.enabled, @@ -341,6 +352,20 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { static String? _addonCollectionToJson(AddonCollection? collection) => collection.mapNotNull((collection) => jsonEncode(collection.encode())); + static UBlockFilterListSettings _ublockFilterListSettingsFromJson( + String? json, + ) => + json.mapNotNull( + (encoded) => UBlockFilterListSettings.fromJson( + jsonDecode(encoded) as Map, + ), + ) ?? + UBlockFilterListSettings(); + + static String _ublockFilterListSettingsToJson( + UBlockFilterListSettings settings, + ) => jsonEncode(settings.toJson()); + factory EngineSettings.fromJson(Map json) => _$EngineSettingsFromJson(json); @@ -363,6 +388,7 @@ class EngineSettings extends GeckoEngineSettings with FastEquatable { queryParameterStripping, bounceTrackingProtectionMode, addonCollection, + ublockFilterListSettings, dohSettingsMode, dohProviderUrl, dohDefaultProviderUrl, diff --git a/apps/weblibre/lib/features/user/data/models/engine_settings.g.dart b/apps/weblibre/lib/features/user/data/models/engine_settings.g.dart index eb70ae1a..cbd3980f 100644 --- a/apps/weblibre/lib/features/user/data/models/engine_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/engine_settings.g.dart @@ -53,6 +53,10 @@ abstract class _$EngineSettingsCWProxy { EngineSettings addonCollection(AddonCollection? addonCollection); + EngineSettings ublockFilterListSettings( + UBlockFilterListSettings ublockFilterListSettings, + ); + EngineSettings dohSettingsMode(DohSettingsMode dohSettingsMode); EngineSettings dohProviderUrl(String dohProviderUrl); @@ -152,6 +156,7 @@ abstract class _$EngineSettingsCWProxy { QueryParameterStripping queryParameterStripping, BounceTrackingProtectionMode bounceTrackingProtectionMode, AddonCollection? addonCollection, + UBlockFilterListSettings ublockFilterListSettings, DohSettingsMode dohSettingsMode, String dohProviderUrl, String dohDefaultProviderUrl, @@ -271,6 +276,11 @@ class _$EngineSettingsCWProxyImpl implements _$EngineSettingsCWProxy { EngineSettings addonCollection(AddonCollection? addonCollection) => call(addonCollection: addonCollection); + @override + EngineSettings ublockFilterListSettings( + UBlockFilterListSettings ublockFilterListSettings, + ) => call(ublockFilterListSettings: ublockFilterListSettings); + @override EngineSettings dohSettingsMode(DohSettingsMode dohSettingsMode) => call(dohSettingsMode: dohSettingsMode); @@ -442,6 +452,7 @@ class _$EngineSettingsCWProxyImpl implements _$EngineSettingsCWProxy { Object? queryParameterStripping = const $CopyWithPlaceholder(), Object? bounceTrackingProtectionMode = const $CopyWithPlaceholder(), Object? addonCollection = const $CopyWithPlaceholder(), + Object? ublockFilterListSettings = const $CopyWithPlaceholder(), Object? dohSettingsMode = const $CopyWithPlaceholder(), Object? dohProviderUrl = const $CopyWithPlaceholder(), Object? dohDefaultProviderUrl = const $CopyWithPlaceholder(), @@ -554,6 +565,12 @@ class _$EngineSettingsCWProxyImpl implements _$EngineSettingsCWProxy { ? _value.addonCollection // ignore: cast_nullable_to_non_nullable : addonCollection as AddonCollection?, + ublockFilterListSettings: + ublockFilterListSettings == const $CopyWithPlaceholder() || + ublockFilterListSettings == null + ? _value.ublockFilterListSettings + // ignore: cast_nullable_to_non_nullable + : ublockFilterListSettings as UBlockFilterListSettings, dohSettingsMode: dohSettingsMode == const $CopyWithPlaceholder() || dohSettingsMode == null @@ -777,6 +794,9 @@ EngineSettings _$EngineSettingsFromJson( addonCollection: EngineSettings._addonCollectionFromJson( json['addonCollection'] as String?, ), + ublockFilterListSettings: EngineSettings._ublockFilterListSettingsFromJson( + json['ublockFilterListSettings'] as String?, + ), dohSettingsMode: $enumDecodeNullable( _$DohSettingsModeEnumMap, json['dohSettingsMode'], @@ -893,6 +913,9 @@ Map _$EngineSettingsToJson( 'addonCollection': EngineSettings._addonCollectionToJson( instance.addonCollection, ), + 'ublockFilterListSettings': EngineSettings._ublockFilterListSettingsToJson( + instance.ublockFilterListSettings, + ), 'dohSettingsMode': _$DohSettingsModeEnumMap[instance.dohSettingsMode]!, 'dohProviderUrl': instance.dohProviderUrl, 'dohDefaultProviderUrl': instance.dohDefaultProviderUrl, diff --git a/apps/weblibre/lib/features/user/data/models/ublock_asset.dart b/apps/weblibre/lib/features/user/data/models/ublock_asset.dart new file mode 100644 index 00000000..5552e101 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/models/ublock_asset.dart @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:json_annotation/json_annotation.dart'; + +part 'ublock_asset.g.dart'; + +enum UBlockAssetGroup { + @JsonValue('default') + $default, + @JsonValue('ads') + ads, + @JsonValue('privacy') + privacy, + @JsonValue('malware') + malware, + @JsonValue('annoyances') + annoyances, + @JsonValue('multipurpose') + multipurpose, + @JsonValue('regions') + regions; + + String get label => switch (this) { + $default => 'Default', + ads => 'Ads', + privacy => 'Privacy', + malware => 'Malware', + annoyances => 'Annoyances', + multipurpose => 'Multipurpose', + regions => 'Regions', + }; + + static const displayOrder = UBlockAssetGroup.values; +} + +enum UBlockAssetSubGroup { + @JsonValue('cookies') + cookies, + @JsonValue('social') + social; + + String get label => switch (this) { + cookies => 'Cookie Notices', + social => 'Social Widgets', + }; +} + +List _contentUrlFromJson(dynamic value) { + if (value is List) return value.cast(); + if (value is String) return [value]; + return []; +} + +dynamic _contentUrlToJson(List value) { + if (value.length == 1) return value.first; + return value; +} + +@JsonSerializable() +class UBlockAssetEntry { + final String content; + + @JsonKey(includeIfNull: false) + final UBlockAssetGroup? group; + + @JsonKey(includeIfNull: false) + final UBlockAssetSubGroup? group2; + + @JsonKey(includeIfNull: false) + final String? parent; + + @JsonKey(includeIfNull: false) + final String? title; + + @JsonKey( + includeIfNull: false, + fromJson: _contentUrlFromJson, + toJson: _contentUrlToJson, + ) + final List contentURL; + + @JsonKey(includeIfNull: false) + final List? cdnURLs; + + @JsonKey(includeIfNull: false) + final List? patchURLs; + + @JsonKey(includeIfNull: false) + final String? supportURL; + + @JsonKey(includeIfNull: false) + final String? instructionURL; + + @JsonKey(includeIfNull: false) + final String? tags; + + @JsonKey(includeIfNull: false) + final String? lang; + + @JsonKey(includeIfNull: false) + final String? ua; + + @JsonKey(includeIfNull: false, defaultValue: false) + final bool off; + + @JsonKey(includeIfNull: false, defaultValue: false) + final bool preferred; + + @JsonKey(includeIfNull: false) + final int? updateAfter; + + const UBlockAssetEntry({ + required this.content, + this.group, + this.group2, + this.parent, + this.title, + this.contentURL = const [], + this.cdnURLs, + this.patchURLs, + this.supportURL, + this.instructionURL, + this.tags, + this.lang, + this.ua, + this.off = false, + this.preferred = false, + this.updateAfter, + }); + + bool get isFilterList => content == 'filters'; + + bool get isDefaultEnabled => !off; + + UBlockAssetGroup get effectiveGroup => + group2?.toGroup() ?? group ?? UBlockAssetGroup.ads; + + factory UBlockAssetEntry.fromJson(Map json) => + _$UBlockAssetEntryFromJson(json); + + Map toJson() => _$UBlockAssetEntryToJson(this); +} + +extension on UBlockAssetSubGroup { + UBlockAssetGroup toGroup() => switch (this) { + UBlockAssetSubGroup.cookies => UBlockAssetGroup.annoyances, + UBlockAssetSubGroup.social => UBlockAssetGroup.annoyances, + }; +} + +class UBlockAssetsRegistry { + final Map _entries; + + UBlockAssetsRegistry(this._entries); + + Map get filterEntries => + Map.fromEntries(_entries.entries.where((e) => e.value.isFilterList)); + + List get defaultEnabledTokens => _entries.entries + .where((e) => e.value.isFilterList && e.value.isDefaultEnabled) + .map((e) => e.key) + .toList(); + + UBlockAssetEntry? operator [](String key) => _entries[key]; + + Map>> buildGroupedParentTree() { + final result = >>{}; + + for (final group in UBlockAssetGroup.displayOrder) { + final groupEntries = >{}; + final processedKeys = {}; + + final groupSubGroups = UBlockAssetSubGroup.values + .where((sg) => sg.toGroup() == group) + .toList(); + + for (final subGroup in groupSubGroups) { + for (final entry in _entries.entries.where( + (e) => e.value.isFilterList && e.value.group2 == subGroup, + )) { + final parentKey = entry.value.parent; + groupEntries.putIfAbsent(parentKey, () => []).add(entry.key); + processedKeys.add(entry.key); + } + } + + for (final entry in _entries.entries.where( + (e) => + e.value.isFilterList && + e.value.group == group && + e.value.group2 == null && + !processedKeys.contains(e.key), + )) { + final parentKey = entry.value.parent; + groupEntries.putIfAbsent(parentKey, () => []).add(entry.key); + } + + if (groupEntries.isNotEmpty) { + result[group] = groupEntries; + } + } + + return result; + } + + List tokensMatchingLocales(Iterable languageCodes) { + final primaryCodes = languageCodes.map((code) { + final parts = code.split('-'); + return parts.first.toLowerCase(); + }).toSet(); + + return _entries.entries + .where((e) { + if (!e.value.isFilterList || !e.value.off) return false; + final lang = e.value.lang; + if (lang == null) return false; + final entryLangs = lang + .split(RegExp(r'\s+')) + .map((l) => l.toLowerCase()) + .toSet(); + return primaryCodes.intersection(entryLangs).isNotEmpty; + }) + .map((e) => e.key) + .toList(); + } + + int enabledCountInGroup(UBlockAssetGroup group, Set enabledTokens) { + var count = 0; + for (final entry in _entries.entries) { + if (!entry.value.isFilterList) continue; + if (entry.value.effectiveGroup != group) continue; + if (enabledTokens.contains(entry.key)) count++; + } + return count; + } + + int totalCountInGroup(UBlockAssetGroup group) { + var count = 0; + for (final entry in _entries.entries) { + if (!entry.value.isFilterList) continue; + if (entry.value.effectiveGroup != group) continue; + count++; + } + return count; + } + + static UBlockAssetsRegistry fromJson(Map json) { + final entries = {}; + for (final entry in json.entries) { + if (entry.value is Map) { + entries[entry.key] = UBlockAssetEntry.fromJson( + entry.value as Map, + ); + } + } + return UBlockAssetsRegistry(entries); + } +} diff --git a/apps/weblibre/lib/features/user/data/models/ublock_asset.g.dart b/apps/weblibre/lib/features/user/data/models/ublock_asset.g.dart new file mode 100644 index 00000000..76cab1c2 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/models/ublock_asset.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ublock_asset.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UBlockAssetEntry _$UBlockAssetEntryFromJson(Map json) => + UBlockAssetEntry( + content: json['content'] as String, + group: $enumDecodeNullable(_$UBlockAssetGroupEnumMap, json['group']), + group2: $enumDecodeNullable(_$UBlockAssetSubGroupEnumMap, json['group2']), + parent: json['parent'] as String?, + title: json['title'] as String?, + contentURL: json['contentURL'] == null + ? const [] + : _contentUrlFromJson(json['contentURL']), + cdnURLs: (json['cdnURLs'] as List?) + ?.map((e) => e as String) + .toList(), + patchURLs: (json['patchURLs'] as List?) + ?.map((e) => e as String) + .toList(), + supportURL: json['supportURL'] as String?, + instructionURL: json['instructionURL'] as String?, + tags: json['tags'] as String?, + lang: json['lang'] as String?, + ua: json['ua'] as String?, + off: json['off'] as bool? ?? false, + preferred: json['preferred'] as bool? ?? false, + updateAfter: (json['updateAfter'] as num?)?.toInt(), + ); + +Map _$UBlockAssetEntryToJson(UBlockAssetEntry instance) => + { + 'content': instance.content, + 'group': ?_$UBlockAssetGroupEnumMap[instance.group], + 'group2': ?_$UBlockAssetSubGroupEnumMap[instance.group2], + 'parent': ?instance.parent, + 'title': ?instance.title, + 'contentURL': ?_contentUrlToJson(instance.contentURL), + 'cdnURLs': ?instance.cdnURLs, + 'patchURLs': ?instance.patchURLs, + 'supportURL': ?instance.supportURL, + 'instructionURL': ?instance.instructionURL, + 'tags': ?instance.tags, + 'lang': ?instance.lang, + 'ua': ?instance.ua, + 'off': instance.off, + 'preferred': instance.preferred, + 'updateAfter': ?instance.updateAfter, + }; + +const _$UBlockAssetGroupEnumMap = { + UBlockAssetGroup.$default: 'default', + UBlockAssetGroup.ads: 'ads', + UBlockAssetGroup.privacy: 'privacy', + UBlockAssetGroup.malware: 'malware', + UBlockAssetGroup.annoyances: 'annoyances', + UBlockAssetGroup.multipurpose: 'multipurpose', + UBlockAssetGroup.regions: 'regions', +}; + +const _$UBlockAssetSubGroupEnumMap = { + UBlockAssetSubGroup.cookies: 'cookies', + UBlockAssetSubGroup.social: 'social', +}; diff --git a/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.dart b/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.dart new file mode 100644 index 00000000..c8fbec9c --- /dev/null +++ b/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.dart @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/user/data/models/ublock_asset.dart'; + +part 'ublock_filter_list_settings.g.dart'; + +const kUBlockMaxExternalUrls = 32; + +const kUBlockHardeningStockTokens = [ + 'adguard-mobile', + 'adguard-mobile-app-banners', + 'adguard-spyware-url', + 'fanboy-cookiemonster', + 'fanboy-social', + 'ublock-annoyances', +]; + +final kUBlockHardeningExternalLists = [ + UBlockExternalList( + url: + 'https://raw.githubusercontent.com/DandelionSprout/adfilt/master/LegitimateURLShortener.txt', + description: 'Legitimate URL Shortener Tool (DandelionSprout)', + ), +]; + +@CopyWith() +@JsonSerializable(includeIfNull: true) +class UBlockExternalList with FastEquatable { + final String url; + + @JsonKey(includeIfNull: false) + final String? description; + + UBlockExternalList({required this.url, this.description}); + + factory UBlockExternalList.fromJson(Map json) => + _$UBlockExternalListFromJson(json); + + Map toJson() => _$UBlockExternalListToJson(this); + + @override + List get hashParameters => [url, description]; +} + +@CopyWith() +@JsonSerializable(includeIfNull: true) +class UBlockFilterListSettings with FastEquatable { + static const String _userFiltersToken = 'user-filters'; + + final bool enabled; + + final List enabledStockListTokens; + + final List autoEnabledStockListTokens; + + final bool autoSelectRegionalLists; + + final List externalFilterLists; + + UBlockFilterListSettings({ + this.enabled = false, + this.enabledStockListTokens = const [], + this.autoEnabledStockListTokens = const [], + this.autoSelectRegionalLists = false, + this.externalFilterLists = const [], + }); + + factory UBlockFilterListSettings.managedDefaults(UBlockAssetsRegistry registry) { + return UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: registry.defaultEnabledTokens, + ); + } + + factory UBlockFilterListSettings.optimizedDefaults( + UBlockAssetsRegistry registry, + ) { + final defaultTokens = registry.defaultEnabledTokens; + + final stockTokens = [...defaultTokens]; + for (final token in kUBlockHardeningStockTokens) { + if (!stockTokens.contains(token)) { + stockTokens.add(token); + } + } + + final externals = []; + final seenUrls = {}; + for (final entry in kUBlockHardeningExternalLists) { + if (seenUrls.add(entry.url) && + externals.length < kUBlockMaxExternalUrls) { + externals.add(entry); + } + } + + return UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: stockTokens, + externalFilterLists: externals, + ); + } + + factory UBlockFilterListSettings.fromJson(Map json) => + _$UBlockFilterListSettingsFromJson(json); + + Map toJson() => _$UBlockFilterListSettingsToJson(this); + + List resolveFinalList() { + if (!enabled) { + return const []; + } + + final result = []; + final seen = {_userFiltersToken}; + + result.add(_userFiltersToken); + + for (final token in enabledStockListTokens) { + if (seen.add(token)) { + result.add(token); + } + } + + for (final token in autoEnabledStockListTokens) { + if (seen.add(token)) { + result.add(token); + } + } + + for (final entry in externalFilterLists) { + final url = entry.url.trim(); + if (url.isEmpty) continue; + final parsed = Uri.tryParse(url); + if (parsed == null) continue; + if (parsed.scheme != 'http' && parsed.scheme != 'https') continue; + if (!parsed.hasAuthority) continue; + if (seen.add(url)) { + result.add(url); + } + } + + return result; + } + + bool isTokenEnabled(String token) => + enabledStockListTokens.contains(token) || + autoEnabledStockListTokens.contains(token); + + @override + List get hashParameters => [ + enabled, + enabledStockListTokens, + autoEnabledStockListTokens, + autoSelectRegionalLists, + externalFilterLists, + ]; +} diff --git a/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.g.dart b/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.g.dart new file mode 100644 index 00000000..aa5dbf41 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/models/ublock_filter_list_settings.g.dart @@ -0,0 +1,241 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ublock_filter_list_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$UBlockExternalListCWProxy { + UBlockExternalList url(String url); + + UBlockExternalList description(String? description); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `UBlockExternalList(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// UBlockExternalList(...).copyWith(id: 12, name: "My name") + /// ``` + UBlockExternalList call({String url, String? description}); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfUBlockExternalList.copyWith(...)` or call `instanceOfUBlockExternalList.copyWith.fieldName(value)` for a single field. +class _$UBlockExternalListCWProxyImpl implements _$UBlockExternalListCWProxy { + const _$UBlockExternalListCWProxyImpl(this._value); + + final UBlockExternalList _value; + + @override + UBlockExternalList url(String url) => call(url: url); + + @override + UBlockExternalList description(String? description) => + call(description: description); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `UBlockExternalList(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// UBlockExternalList(...).copyWith(id: 12, name: "My name") + /// ``` + UBlockExternalList call({ + Object? url = const $CopyWithPlaceholder(), + Object? description = const $CopyWithPlaceholder(), + }) { + return UBlockExternalList( + url: url == const $CopyWithPlaceholder() || url == null + ? _value.url + // ignore: cast_nullable_to_non_nullable + : url as String, + description: description == const $CopyWithPlaceholder() + ? _value.description + // ignore: cast_nullable_to_non_nullable + : description as String?, + ); + } +} + +extension $UBlockExternalListCopyWith on UBlockExternalList { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfUBlockExternalList.copyWith(...)` or `instanceOfUBlockExternalList.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$UBlockExternalListCWProxy get copyWith => + _$UBlockExternalListCWProxyImpl(this); +} + +abstract class _$UBlockFilterListSettingsCWProxy { + UBlockFilterListSettings enabled(bool enabled); + + UBlockFilterListSettings enabledStockListTokens( + List enabledStockListTokens, + ); + + UBlockFilterListSettings autoEnabledStockListTokens( + List autoEnabledStockListTokens, + ); + + UBlockFilterListSettings autoSelectRegionalLists( + bool autoSelectRegionalLists, + ); + + UBlockFilterListSettings externalFilterLists( + List externalFilterLists, + ); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `UBlockFilterListSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// UBlockFilterListSettings(...).copyWith(id: 12, name: "My name") + /// ``` + UBlockFilterListSettings call({ + bool enabled, + List enabledStockListTokens, + List autoEnabledStockListTokens, + bool autoSelectRegionalLists, + List externalFilterLists, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfUBlockFilterListSettings.copyWith(...)` or call `instanceOfUBlockFilterListSettings.copyWith.fieldName(value)` for a single field. +class _$UBlockFilterListSettingsCWProxyImpl + implements _$UBlockFilterListSettingsCWProxy { + const _$UBlockFilterListSettingsCWProxyImpl(this._value); + + final UBlockFilterListSettings _value; + + @override + UBlockFilterListSettings enabled(bool enabled) => call(enabled: enabled); + + @override + UBlockFilterListSettings enabledStockListTokens( + List enabledStockListTokens, + ) => call(enabledStockListTokens: enabledStockListTokens); + + @override + UBlockFilterListSettings autoEnabledStockListTokens( + List autoEnabledStockListTokens, + ) => call(autoEnabledStockListTokens: autoEnabledStockListTokens); + + @override + UBlockFilterListSettings autoSelectRegionalLists( + bool autoSelectRegionalLists, + ) => call(autoSelectRegionalLists: autoSelectRegionalLists); + + @override + UBlockFilterListSettings externalFilterLists( + List externalFilterLists, + ) => call(externalFilterLists: externalFilterLists); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `UBlockFilterListSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// UBlockFilterListSettings(...).copyWith(id: 12, name: "My name") + /// ``` + UBlockFilterListSettings call({ + Object? enabled = const $CopyWithPlaceholder(), + Object? enabledStockListTokens = const $CopyWithPlaceholder(), + Object? autoEnabledStockListTokens = const $CopyWithPlaceholder(), + Object? autoSelectRegionalLists = const $CopyWithPlaceholder(), + Object? externalFilterLists = const $CopyWithPlaceholder(), + }) { + return UBlockFilterListSettings( + enabled: enabled == const $CopyWithPlaceholder() || enabled == null + ? _value.enabled + // ignore: cast_nullable_to_non_nullable + : enabled as bool, + enabledStockListTokens: + enabledStockListTokens == const $CopyWithPlaceholder() || + enabledStockListTokens == null + ? _value.enabledStockListTokens + // ignore: cast_nullable_to_non_nullable + : enabledStockListTokens as List, + autoEnabledStockListTokens: + autoEnabledStockListTokens == const $CopyWithPlaceholder() || + autoEnabledStockListTokens == null + ? _value.autoEnabledStockListTokens + // ignore: cast_nullable_to_non_nullable + : autoEnabledStockListTokens as List, + autoSelectRegionalLists: + autoSelectRegionalLists == const $CopyWithPlaceholder() || + autoSelectRegionalLists == null + ? _value.autoSelectRegionalLists + // ignore: cast_nullable_to_non_nullable + : autoSelectRegionalLists as bool, + externalFilterLists: + externalFilterLists == const $CopyWithPlaceholder() || + externalFilterLists == null + ? _value.externalFilterLists + // ignore: cast_nullable_to_non_nullable + : externalFilterLists as List, + ); + } +} + +extension $UBlockFilterListSettingsCopyWith on UBlockFilterListSettings { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfUBlockFilterListSettings.copyWith(...)` or `instanceOfUBlockFilterListSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$UBlockFilterListSettingsCWProxy get copyWith => + _$UBlockFilterListSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UBlockExternalList _$UBlockExternalListFromJson(Map json) => + UBlockExternalList( + url: json['url'] as String, + description: json['description'] as String?, + ); + +Map _$UBlockExternalListToJson(UBlockExternalList instance) => + { + 'url': instance.url, + 'description': ?instance.description, + }; + +UBlockFilterListSettings _$UBlockFilterListSettingsFromJson( + Map json, +) => UBlockFilterListSettings( + enabled: json['enabled'] as bool? ?? false, + enabledStockListTokens: + (json['enabledStockListTokens'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + autoEnabledStockListTokens: + (json['autoEnabledStockListTokens'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + autoSelectRegionalLists: json['autoSelectRegionalLists'] as bool? ?? false, + externalFilterLists: + (json['externalFilterLists'] as List?) + ?.map((e) => UBlockExternalList.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$UBlockFilterListSettingsToJson( + UBlockFilterListSettings instance, +) => { + 'enabled': instance.enabled, + 'enabledStockListTokens': instance.enabledStockListTokens, + 'autoEnabledStockListTokens': instance.autoEnabledStockListTokens, + 'autoSelectRegionalLists': instance.autoSelectRegionalLists, + 'externalFilterLists': instance.externalFilterLists + .map((e) => e.toJson()) + .toList(), +}; diff --git a/apps/weblibre/lib/features/user/data/providers/ublock_assets.dart b/apps/weblibre/lib/features/user/data/providers/ublock_assets.dart new file mode 100644 index 00000000..0550eca5 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/providers/ublock_assets.dart @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/models/ublock_asset.dart'; + +part 'ublock_assets.g.dart'; + +@Riverpod(keepAlive: true) +Future ublockAssetsRegistry(Ref ref) async { + final jsonStr = await rootBundle.loadString('assets/ublock/assets.json'); + + return UBlockAssetsRegistry.fromJson( + jsonDecode(jsonStr) as Map, + ); +} diff --git a/apps/weblibre/lib/features/user/data/providers/ublock_assets.g.dart b/apps/weblibre/lib/features/user/data/providers/ublock_assets.g.dart new file mode 100644 index 00000000..97e446b5 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/providers/ublock_assets.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ublock_assets.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ublockAssetsRegistry) +final ublockAssetsRegistryProvider = UblockAssetsRegistryProvider._(); + +final class UblockAssetsRegistryProvider + extends + $FunctionalProvider< + AsyncValue, + UBlockAssetsRegistry, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + UblockAssetsRegistryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'ublockAssetsRegistryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$ublockAssetsRegistryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return ublockAssetsRegistry(ref); + } +} + +String _$ublockAssetsRegistryHash() => + r'512d2bf06d16661f5b49b4b4f02840aad5722cd3'; diff --git a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.dart index 91f143b4..0ea09ee3 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.dart @@ -100,6 +100,10 @@ class EngineSettingsRepository extends _$EngineSettingsRepository { DriftSqlType.string, db.typeMapping, ), + 'ublockFilterListSettings': settings['ublockFilterListSettings']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), 'dohSettingsMode': settings['dohSettingsMode']?.readAs( DriftSqlType.string, db.typeMapping, diff --git a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart index ae6db78b..d88da557 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart @@ -34,7 +34,7 @@ final class EngineSettingsRepositoryProvider } String _$engineSettingsRepositoryHash() => - r'f3fe745e37ec89c2d403906250aea66321c3e505'; + r'03cfe93b6d4ac8cdb0b33627f4baa75240bff745'; abstract class _$EngineSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index 956e3139..08cf0638 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'package:background_fetch/background_fetch.dart'; @@ -42,7 +43,9 @@ import 'package:weblibre/core/providers/app_state.dart'; import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/domain/services/app_initialization.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; @@ -179,6 +182,14 @@ class _MainWidget extends HookConsumerWidget { final generalSettings = await ref .read(generalSettingsRepositoryProvider.notifier) .fetchSettings(); + final startupUBlockFilterListsPref = + engineSettings.ublockFilterListSettings.enabled + ? jsonEncode( + engineSettings.ublockFilterListSettings.resolveFinalList(), + ) + : null; + final clearStartupUBlockFilterListsPref = + !engineSettings.ublockFilterListSettings.enabled; try { await GeckoBrowserService().initialize( @@ -189,6 +200,8 @@ class _MainWidget extends HookConsumerWidget { generalSettings.syncServerOverride, generalSettings.syncTokenServerOverride, engineSettings, + startupUBlockFilterListsPref, + clearStartupUBlockFilterListsPref, ); } on PlatformException catch (e, s) { logger.e( @@ -206,6 +219,21 @@ class _MainWidget extends HookConsumerWidget { rethrow; } + // Mirror the startup uBO pref into the fixator so later pref changes are + // still observed and enforced after native startup initialization. + try { + await syncUBlockFilterLists( + ref.read(preferenceFixatorProvider.notifier), + engineSettings, + ); + } catch (e, s) { + logger.w( + 'Failed to sync uBlock filter list pref at startup', + error: e, + stackTrace: s, + ); + } + await ref.read(appInitializationServiceProvider.notifier).initialize(); Future preloadUrlCleanerCatalog() async { diff --git a/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart b/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart index 3f309429..ebf0e9dc 100644 --- a/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart +++ b/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart @@ -26,6 +26,7 @@ class UriBreadcrumb extends StatelessWidget { final Uri uri; final Widget? icon; final TextStyle? style; + final bool showHttpScheme; final void Function()? onTooltipTriggered; const UriBreadcrumb({ @@ -33,6 +34,7 @@ class UriBreadcrumb extends StatelessWidget { required this.uri, this.icon, this.style, + this.showHttpScheme = true, this.onTooltipTriggered, }); @@ -56,6 +58,22 @@ class UriBreadcrumb extends StatelessWidget { children: [ ?icon, if (icon != null) const SizedBox(width: 6), + if (!uri.isHttpOrHttps || showHttpScheme) ...[ + Text( + uri.scheme, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Text( + ' β€Ί ', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], Text( uri.authority, maxLines: 1, diff --git a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart index 5f0bf7c6..019e5b0c 100644 --- a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart +++ b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart @@ -27,6 +27,7 @@ class UrlListTile extends StatelessWidget { final Widget? leading; final Widget? trailing; final Color? borderColor; + final bool showHttpScheme; final VoidCallback? onTap; const UrlListTile({ @@ -36,6 +37,7 @@ class UrlListTile extends StatelessWidget { this.leading, this.trailing, this.borderColor, + this.showHttpScheme = true, this.onTap, }); @@ -90,6 +92,7 @@ class UrlListTile extends StatelessWidget { const SizedBox(height: 3.0), UriBreadcrumb( uri: uri, + showHttpScheme: showHttpScheme, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/apps/weblibre/pubspec.yaml b/apps/weblibre/pubspec.yaml index 2d05a838..29cad287 100644 --- a/apps/weblibre/pubspec.yaml +++ b/apps/weblibre/pubspec.yaml @@ -120,6 +120,7 @@ flutter: - assets/legal/ - assets/quotes/ - assets/small_web/ + - assets/ublock/ fonts: - family: TorIcons fonts: diff --git a/apps/weblibre/test/features/user/data/models/ublock_filter_list_settings_test.dart b/apps/weblibre/test/features/user/data/models/ublock_filter_list_settings_test.dart new file mode 100644 index 00000000..c62747ee --- /dev/null +++ b/apps/weblibre/test/features/user/data/models/ublock_filter_list_settings_test.dart @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/user/data/models/ublock_asset.dart'; +import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart'; + +UBlockAssetsRegistry _makeRegistry({ + List>? extra, +}) { + final entries = { + 'ublock-filters': const UBlockAssetEntry( + content: 'filters', + group: UBlockAssetGroup.$default, + parent: 'uBlock filters', + title: 'uBlock filters – Ads', + tags: 'ads', + contentURL: ['https://example.com/filters.txt'], + ), + 'ublock-privacy': const UBlockAssetEntry( + content: 'filters', + group: UBlockAssetGroup.$default, + parent: 'uBlock filters', + title: 'uBlock filters – Privacy', + tags: 'privacy', + contentURL: ['https://example.com/privacy.txt'], + ), + 'easylist': const UBlockAssetEntry( + content: 'filters', + group: UBlockAssetGroup.ads, + title: 'EasyList', + tags: 'ads', + preferred: true, + contentURL: ['https://example.com/easylist.txt'], + ), + 'DEU-0': const UBlockAssetEntry( + content: 'filters', + group: UBlockAssetGroup.regions, + off: true, + title: 'πŸ‡©πŸ‡ͺde: EasyList Germany', + tags: 'ads german deutsch', + lang: 'de', + contentURL: ['https://example.com/deu.txt'], + ), + 'NLD-0': const UBlockAssetEntry( + content: 'filters', + group: UBlockAssetGroup.regions, + off: true, + title: 'πŸ‡³πŸ‡±nl: EasyList Dutch', + tags: 'ads dutch nederlands', + lang: 'nl', + contentURL: ['https://example.com/nld.txt'], + ), + 'assets.json': const UBlockAssetEntry( + content: 'internal', + updateAfter: 13, + contentURL: ['https://example.com/assets.json'], + ), + }; + + if (extra != null) { + for (final e in extra) { + entries[e.key] = e.value; + } + } + + return UBlockAssetsRegistry(entries); +} + +void main() { + group('UBlockAssetsRegistry', () { + test('defaultEnabledTokens excludes off:true entries', () { + final registry = _makeRegistry(); + expect( + registry.defaultEnabledTokens, + containsAll(['ublock-filters', 'ublock-privacy', 'easylist']), + ); + expect(registry.defaultEnabledTokens, isNot(contains('DEU-0'))); + expect(registry.defaultEnabledTokens, isNot(contains('NLD-0'))); + expect(registry.defaultEnabledTokens, isNot(contains('assets.json'))); + }); + + test('tokensMatchingLocales matches primary language code', () { + final registry = _makeRegistry(); + expect(registry.tokensMatchingLocales(['de-DE']), containsAll(['DEU-0'])); + expect( + registry.tokensMatchingLocales(['de-DE']), + isNot(contains('NLD-0')), + ); + expect(registry.tokensMatchingLocales(['nl-NL']), containsAll(['NLD-0'])); + expect( + registry.tokensMatchingLocales(['nl-NL']), + isNot(contains('DEU-0')), + ); + }); + + test('tokensMatchingLocales matches multiple locales', () { + final registry = _makeRegistry(); + final matched = registry.tokensMatchingLocales(['de-DE', 'nl-NL']); + expect(matched, containsAll(['DEU-0', 'NLD-0'])); + }); + + test('tokensMatchingLocales only matches off:true entries', () { + final registry = _makeRegistry(); + final matched = registry.tokensMatchingLocales(['en-US']); + expect(matched, isEmpty); + }); + + test('buildGroupedParentTree creates correct hierarchy', () { + final registry = _makeRegistry(); + final tree = registry.buildGroupedParentTree(); + + expect(tree, contains(UBlockAssetGroup.$default)); + expect(tree[UBlockAssetGroup.$default]!, contains('uBlock filters')); + expect( + tree[UBlockAssetGroup.$default]!['uBlock filters'], + containsAll(['ublock-filters', 'ublock-privacy']), + ); + expect(tree, contains(UBlockAssetGroup.ads)); + expect(tree[UBlockAssetGroup.ads]!, contains(null)); + expect(tree[UBlockAssetGroup.ads]![null], contains('easylist')); + expect(tree, contains(UBlockAssetGroup.regions)); + }); + + test('fromJson parses a full assets.json structure', () { + final json = + jsonDecode(''' + { + "assets.json": { + "content": "internal", + "updateAfter": 13, + "contentURL": ["https://raw.githubusercontent.com/gorhill/uBlock/master/assets/assets.json"] + }, + "ublock-filters": { + "content": "filters", + "group": "default", + "parent": "uBlock filters", + "title": "uBlock filters – Ads", + "contentURL": "https://example.com/filters.txt", + "off": true + } + } + ''') + as Map; + + final registry = UBlockAssetsRegistry.fromJson(json); + expect(registry['ublock-filters'], isNotNull); + expect(registry['ublock-filters']!.group, UBlockAssetGroup.$default); + expect(registry['ublock-filters']!.parent, 'uBlock filters'); + expect(registry['ublock-filters']!.off, true); + expect(registry['ublock-filters']!.contentURL, [ + 'https://example.com/filters.txt', + ]); + expect(registry['assets.json'], isNotNull); + expect(registry.defaultEnabledTokens, isEmpty); + }); + }); + + group('UBlockFilterListSettings', () { + final registry = _makeRegistry(); + + test('managedDefaults enables default-on lists without auto tokens', () { + final settings = UBlockFilterListSettings.managedDefaults(registry); + + expect(settings.enabled, true); + expect( + settings.enabledStockListTokens, + containsAll(['ublock-filters', 'ublock-privacy', 'easylist']), + ); + expect(settings.autoSelectRegionalLists, false); + expect(settings.autoEnabledStockListTokens, isEmpty); + }); + + test('resolveFinalList merges manual + auto + external with dedup', () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['ublock-filters', 'easylist'], + autoEnabledStockListTokens: ['DEU-0', 'ublock-filters'], + externalFilterLists: [ + UBlockExternalList(url: 'https://example.com/external.txt'), + ], + ); + + final list = settings.resolveFinalList(); + + expect(list.first, 'user-filters'); + expect( + list, + containsAll([ + 'ublock-filters', + 'easylist', + 'DEU-0', + 'https://example.com/external.txt', + ]), + ); + expect(list.where((t) => t == 'ublock-filters').length, 1); + }); + + test('resolveFinalList returns empty when disabled', () { + final settings = UBlockFilterListSettings( + enabled: false, + enabledStockListTokens: ['ublock-filters'], + ); + + expect(settings.resolveFinalList(), isEmpty); + }); + + test('isTokenEnabled checks both manual and auto lists', () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoEnabledStockListTokens: ['DEU-0'], + ); + + expect(settings.isTokenEnabled('easylist'), true); + expect(settings.isTokenEnabled('DEU-0'), true); + expect(settings.isTokenEnabled('ublock-filters'), false); + }); + + test( + 'toggling off an auto-only token removes it from autoEnabledStockListTokens', + () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoEnabledStockListTokens: ['DEU-0', 'NLD-0'], + ); + + final updated = settings.copyWith( + enabledStockListTokens: [...settings.enabledStockListTokens] + ..remove('DEU-0'), + autoEnabledStockListTokens: [...settings.autoEnabledStockListTokens] + ..remove('DEU-0'), + ); + + expect(updated.autoEnabledStockListTokens, isNot(contains('DEU-0'))); + expect(updated.autoEnabledStockListTokens, contains('NLD-0')); + expect(updated.isTokenEnabled('DEU-0'), false); + expect(updated.isTokenEnabled('NLD-0'), true); + }, + ); + + test( + 'toggling off a manual-only token removes it from enabledStockListTokens', + () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist', 'ublock-filters'], + autoEnabledStockListTokens: ['DEU-0'], + ); + + final updated = settings.copyWith( + enabledStockListTokens: [...settings.enabledStockListTokens] + ..remove('easylist'), + autoEnabledStockListTokens: [...settings.autoEnabledStockListTokens] + ..remove('easylist'), + ); + + expect(updated.enabledStockListTokens, isNot(contains('easylist'))); + expect(updated.enabledStockListTokens, contains('ublock-filters')); + expect(updated.isTokenEnabled('easylist'), false); + }, + ); + + test( + 'toggling off a token in both sets removes it from both', + () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['DEU-0', 'easylist'], + autoEnabledStockListTokens: ['DEU-0', 'NLD-0'], + ); + + expect(settings.isTokenEnabled('DEU-0'), true); + + final updated = settings.copyWith( + enabledStockListTokens: [...settings.enabledStockListTokens] + ..remove('DEU-0'), + autoEnabledStockListTokens: [...settings.autoEnabledStockListTokens] + ..remove('DEU-0'), + ); + + expect(updated.enabledStockListTokens, isNot(contains('DEU-0'))); + expect(updated.autoEnabledStockListTokens, isNot(contains('DEU-0'))); + expect(updated.isTokenEnabled('DEU-0'), false); + expect(updated.isTokenEnabled('NLD-0'), true); + expect(updated.isTokenEnabled('easylist'), true); + }, + ); + + test('toggling on a disabled token adds to enabledStockListTokens', () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoEnabledStockListTokens: ['DEU-0'], + ); + + final updated = settings.copyWith.enabledStockListTokens([ + ...settings.enabledStockListTokens, + 'ublock-filters', + ]); + + expect(updated.enabledStockListTokens, contains('ublock-filters')); + expect(updated.isTokenEnabled('ublock-filters'), true); + }); + + test('disabling auto-select clears autoEnabledStockListTokens', () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoEnabledStockListTokens: ['DEU-0'], + autoSelectRegionalLists: true, + ); + + final updated = settings.copyWith( + autoSelectRegionalLists: false, + autoEnabledStockListTokens: [], + ); + + expect(updated.autoSelectRegionalLists, false); + expect(updated.autoEnabledStockListTokens, isEmpty); + expect(updated.enabledStockListTokens, contains('easylist')); + }); + + test( + 're-enabling auto-select recomputes autoEnabledStockListTokens from registry', + () { + var settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoSelectRegionalLists: false, + autoEnabledStockListTokens: [], + ); + + final langCodes = ['de-DE', 'nl-NL']; + final autoTokens = registry.tokensMatchingLocales(langCodes); + settings = settings.copyWith( + autoSelectRegionalLists: true, + autoEnabledStockListTokens: autoTokens, + ); + + expect(settings.autoSelectRegionalLists, true); + expect( + settings.autoEnabledStockListTokens, + containsAll(['DEU-0', 'NLD-0']), + ); + }, + ); + + test('fromJson/toJson round-trips with new fields', () { + final settings = UBlockFilterListSettings( + enabled: true, + enabledStockListTokens: ['easylist'], + autoEnabledStockListTokens: ['DEU-0'], + autoSelectRegionalLists: true, + externalFilterLists: [ + UBlockExternalList( + url: 'https://example.com/list.txt', + description: 'Test list', + ), + ], + ); + + final json = settings.toJson(); + final restored = UBlockFilterListSettings.fromJson(json); + + expect(restored.enabled, settings.enabled); + expect(restored.enabledStockListTokens, settings.enabledStockListTokens); + expect( + restored.autoEnabledStockListTokens, + settings.autoEnabledStockListTokens, + ); + expect( + restored.autoSelectRegionalLists, + settings.autoSelectRegionalLists, + ); + expect(restored.externalFilterLists, settings.externalFilterLists); + }); + + test('fromJson defaults new fields for legacy data', () { + final legacyJson = { + 'enabled': true, + 'enabledStockListTokens': ['easylist'], + 'externalFilterLists': [], + }; + + final restored = UBlockFilterListSettings.fromJson(legacyJson); + + expect(restored.autoEnabledStockListTokens, isEmpty); + expect(restored.autoSelectRegionalLists, false); + }); + }); +} diff --git a/apps/weblibre/test/presentation/widgets/uri_breadcrumb_test.dart b/apps/weblibre/test/presentation/widgets/uri_breadcrumb_test.dart new file mode 100644 index 00000000..a129aafb --- /dev/null +++ b/apps/weblibre/test/presentation/widgets/uri_breadcrumb_test.dart @@ -0,0 +1,143 @@ +/* + * 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) option later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; + +void main() { + Widget buildSubject({required Uri uri, bool showHttpScheme = true}) { + return MaterialApp( + home: Scaffold( + body: UriBreadcrumb(uri: uri, showHttpScheme: showHttpScheme), + ), + ); + } + + List boldTexts(WidgetTester tester) { + return tester + .widgetList(find.byType(Text)) + .where((t) => t.style?.fontWeight == FontWeight.bold) + .map((t) => t.data ?? '') + .toList(); + } + + List allTexts(WidgetTester tester) { + return tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .toList(); + } + + group('UriBreadcrumb', () { + group('showHttpScheme defaults to true', () { + testWidgets('shows scheme before authority for https URLs', ( + tester, + ) async { + await tester.pumpWidget( + buildSubject(uri: Uri.parse('https://example.com/path')), + ); + + final all = allTexts(tester); + expect(all, contains('https')); + expect(all, contains(' β€Ί ')); + final bold = boldTexts(tester); + expect(bold, contains('example.com')); + expect(bold, contains('https')); + }); + + testWidgets('shows scheme before authority for http URLs', ( + tester, + ) async { + await tester.pumpWidget( + buildSubject(uri: Uri.parse('http://example.com')), + ); + + final all = allTexts(tester); + expect(all, contains('http')); + expect(all, contains(' β€Ί ')); + final bold = boldTexts(tester); + expect(bold, contains('example.com')); + expect(bold, contains('http')); + }); + + testWidgets('shows scheme before authority for non-HTTP URLs', ( + tester, + ) async { + await tester.pumpWidget( + buildSubject(uri: Uri.parse('ftp://files.example.com/docs')), + ); + + final all = allTexts(tester); + expect(all, contains('ftp')); + expect(all, contains(' β€Ί ')); + final bold = boldTexts(tester); + expect(bold, contains('files.example.com')); + expect(bold, contains('ftp')); + }); + }); + + group('showHttpScheme is false', () { + testWidgets('hides scheme for https URLs', (tester) async { + await tester.pumpWidget( + buildSubject( + uri: Uri.parse('https://example.com/path'), + showHttpScheme: false, + ), + ); + + final all = allTexts(tester); + expect(all, isNot(contains('https'))); + final bold = boldTexts(tester); + expect(bold, ['example.com']); + }); + + testWidgets('hides scheme for http URLs', (tester) async { + await tester.pumpWidget( + buildSubject( + uri: Uri.parse('http://example.com'), + showHttpScheme: false, + ), + ); + + final all = allTexts(tester); + expect(all, isNot(contains('http'))); + final bold = boldTexts(tester); + expect(bold, ['example.com']); + }); + + testWidgets('still shows scheme for non-HTTP URLs', (tester) async { + await tester.pumpWidget( + buildSubject( + uri: Uri.parse('ftp://files.example.com/docs'), + showHttpScheme: false, + ), + ); + + final all = allTexts(tester); + expect(all, contains('ftp')); + expect(all, contains(' β€Ί ')); + final bold = boldTexts(tester); + expect(bold, contains('files.example.com')); + expect(bold, contains('ftp')); + }); + }); + }); +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index ce8b66c2..43f6a636 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -36,7 +36,9 @@ import mozilla.components.browser.state.action.RestoreCompleteAction import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.CustomTabListAction import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.ExperimentalAndroidComponentsApi import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.concept.engine.preferences.Branch import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider import mozilla.components.support.base.facts.Facts import mozilla.components.support.base.facts.processor.LogFactProcessor @@ -50,6 +52,7 @@ import java.util.concurrent.TimeUnit private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 // 14 days private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST = "__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid" +private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists" object GlobalComponents { private var _components: Components? = null @@ -87,6 +90,10 @@ object GlobalComponents { // Startup settings for builder-only GeckoRuntimeSettings (fission, process isolation, etc.) var startupSettings: GeckoEngineSettings? = null + // Startup pref written before web extensions initialize. + var startupUBlockFilterListsPref: String? = null + var clearStartupUBlockFilterListsPref: Boolean = false + fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean { return when (engineSettingsApi!!.getAppLinksMode()) { eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true @@ -142,6 +149,40 @@ object GlobalComponents { newComponents.useCases.downloadsUseCases.restoreDownloads() } + // Submits the uBO managed-storage pref before web extensions register. + // Called from setUp() on the main thread; we do not block awaiting the + // ack callback because the engine dispatches it back to the main thread + // (which is held by setUp), and waiting would deadlock. The underlying + // GeckoView pref store accepts the new value before the callback fires, + // so by the time WebExtensionSupport.initialize installs uBO and the + // extension reads storage.managed, the pref is already present. + @OptIn(ExperimentalAndroidComponentsApi::class) + private fun applyStartupUBlockFilterListsPref(newComponents: Components) { + if (!clearStartupUBlockFilterListsPref && startupUBlockFilterListsPref == null) { + return + } + + val onError: (Throwable) -> Unit = { + Logger.warn("Failed applying startup uBlock filter list pref", it) + } + + if (clearStartupUBlockFilterListsPref) { + newComponents.core.engine.clearBrowserUserPref( + pref = UBLOCK_FILTER_LISTS_PREF, + onSuccess = {}, + onError = onError, + ) + } else { + newComponents.core.engine.setBrowserPref( + UBLOCK_FILTER_LISTS_PREF, + requireNotNull(startupUBlockFilterListsPref), + Branch.USER, + onSuccess = {}, + onError = onError, + ) + } + } + @OptIn(DelicateCoroutinesApi::class) fun setUp( applicationContext: ProfileContext, @@ -206,6 +247,7 @@ object GlobalComponents { if (mode == ComponentsMode.FULL) { newComponents.core.engine.warmUp() + applyStartupUBlockFilterListsPref(newComponents) } fun restorePreviousCustomTabs() { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index bc27c720..27477989 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -180,6 +180,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { fxaServerOverride: String?, syncTokenServerOverride: String?, startupSettings: GeckoEngineSettings?, + startupUBlockFilterListsPref: String?, + clearStartupUBlockFilterListsPref: Boolean, ) { synchronized(this) { if (!isGeckoInitialized) { @@ -196,6 +198,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { // Store startup settings before runtime creation GlobalComponents.startupSettings = startupSettings + GlobalComponents.startupUBlockFilterListsPref = startupUBlockFilterListsPref + GlobalComponents.clearStartupUBlockFilterListsPref = + clearStartupUBlockFilterListsPref setupGeckoEngine( profileFolder, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index c4f709ff..9db4c781 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -6282,7 +6282,7 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoBrowserApi { fun getGeckoVersion(): String - fun initialize(profileFolder: String, logLevel: LogLevel, contentBlocking: ContentBlocking, addonCollection: AddonCollection?, fxaServerOverride: String?, syncTokenServerOverride: String?, startupSettings: GeckoEngineSettings?) + fun initialize(profileFolder: String, logLevel: LogLevel, contentBlocking: ContentBlocking, addonCollection: AddonCollection?, fxaServerOverride: String?, syncTokenServerOverride: String?, startupSettings: GeckoEngineSettings?, startupUBlockFilterListsPref: String?, clearStartupUBlockFilterListsPref: Boolean) fun showNativeFragment(): Boolean fun onTrimMemory(level: Long) fun openInCustomTab(url: String, private: Boolean, contextId: String?) @@ -6327,8 +6327,10 @@ interface GeckoBrowserApi { val fxaServerOverrideArg = args[4] as String? val syncTokenServerOverrideArg = args[5] as String? val startupSettingsArg = args[6] as GeckoEngineSettings? + val startupUBlockFilterListsPrefArg = args[7] as String? + val clearStartupUBlockFilterListsPrefArg = args[8] as Boolean val wrapped: List = try { - api.initialize(profileFolderArg, logLevelArg, contentBlockingArg, addonCollectionArg, fxaServerOverrideArg, syncTokenServerOverrideArg, startupSettingsArg) + api.initialize(profileFolderArg, logLevelArg, contentBlockingArg, addonCollectionArg, fxaServerOverrideArg, syncTokenServerOverrideArg, startupSettingsArg, startupUBlockFilterListsPrefArg, clearStartupUBlockFilterListsPrefArg) listOf(null) } catch (exception: Throwable) { GeckoPigeonUtils.wrapError(exception) diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart index 0b14ea17..ee3397fd 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart @@ -25,6 +25,8 @@ class GeckoBrowserService { String? fxaServerOverride, String? syncTokenServerOverride, [ GeckoEngineSettings? startupSettings, + String? startupUBlockFilterListsPref, + bool clearStartupUBlockFilterListsPref = false, ]) { return _api.initialize( profileFolder, @@ -34,6 +36,8 @@ class GeckoBrowserService { fxaServerOverride, syncTokenServerOverride, startupSettings, + startupUBlockFilterListsPref, + clearStartupUBlockFilterListsPref, ); } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index bfeb440c..f99bd115 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -6394,6 +6394,8 @@ class GeckoBrowserApi { String? fxaServerOverride, String? syncTokenServerOverride, GeckoEngineSettings? startupSettings, + String? startupUBlockFilterListsPref, + bool clearStartupUBlockFilterListsPref, ) async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.initialize$pigeonVar_messageChannelSuffix'; @@ -6411,6 +6413,8 @@ class GeckoBrowserApi { fxaServerOverride, syncTokenServerOverride, startupSettings, + startupUBlockFilterListsPref, + clearStartupUBlockFilterListsPref, ]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 6dc37b88..6a31357e 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1328,6 +1328,13 @@ class AddonCollection { @HostApi() abstract class GeckoBrowserApi { String getGeckoVersion(); + // [startupUBlockFilterListsPref] is the JSON-encoded value to write to the + // managed-storage pref read by uBlock Origin at extension startup. The pref + // must be written before the extension registers, so it is plumbed through + // initialize rather than set later. + // If [clearStartupUBlockFilterListsPref] is true, the pref is cleared and + // [startupUBlockFilterListsPref] is ignored. If both are unset/false, the + // pref is left untouched. void initialize( String profileFolder, LogLevel logLevel, @@ -1336,6 +1343,8 @@ abstract class GeckoBrowserApi { String? fxaServerOverride, String? syncTokenServerOverride, GeckoEngineSettings? startupSettings, + String? startupUBlockFilterListsPref, + bool clearStartupUBlockFilterListsPref, ); bool showNativeFragment(); void onTrimMemory(int level); diff --git a/scripts/update-assets.sh b/scripts/update-assets.sh index 6390a562..b5dd92d4 100755 --- a/scripts/update-assets.sh +++ b/scripts/update-assets.sh @@ -93,9 +93,20 @@ update_url_shorteners() { "$dir/url-shortener-list.json" } +update_ublock() { + local dir="$REPO_ROOT/apps/weblibre/assets/ublock" + log "Updating uBlock Origin assets..." + + fetch "https://raw.githubusercontent.com/gorhill/uBlock/master/assets/assets.json" \ + "$dir/assets.json" + + date -u --iso-8601=seconds > "$dir/last_sync.txt" + log "uBlock assets sync completed at $(cat "$dir/last_sync.txt")" +} + # ── main ───────────────────────────────────────────────────────────────────── -ALL_GROUPS=(bangs bridges url-cleaner url-shorteners) +ALL_GROUPS=(bangs bridges url-cleaner url-shorteners ublock) SELECTED_GROUPS=() while [[ $# -gt 0 ]]; do @@ -117,6 +128,7 @@ for group in "${SELECTED_GROUPS[@]}"; do bridges) update_bridges || ((FAILURES++)) ;; url-cleaner) update_url_cleaner || ((FAILURES++)) ;; url-shorteners) update_url_shorteners || ((FAILURES++)) ;; + ublock) update_ublock || ((FAILURES++)) ;; *) err "Unknown group: $group"; ((FAILURES++)) ;; esac done