/* * 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:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/exit_app.dart'; class PrivacySecuritySettingsScreen extends StatelessWidget { const PrivacySecuritySettingsScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Privacy & Security')), body: SafeArea( child: FadingScroll( fadingSize: 25, builder: (context, controller) { return ListView( controller: controller, padding: const EdgeInsets.symmetric(horizontal: 12.0), children: const [ _TrackingProtectionSection(), _FingerprintingSection(), _ConnectionSecuritySection(), _NetworkProtectionSection(), _PrivacySignalsSection(), _DataManagementSection(), _AdvancedSecuritySection(), ], ); }, ), ), ); } } class _FingerprintingSection extends StatelessWidget { const _FingerprintingSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Fingerprinting'), _BrowserLanguagesTile(), _FingerprintProtectionTile(), _ResistFingerprintingTile(), ], ); } } class _TrackingProtectionSection extends StatelessWidget { const _TrackingProtectionSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Tracking Protection'), _EnhancedTrackingProtectionSection(), _BounceTrackingProtectionTile(), _QueryParameterStrippingSection(), _TrackingProtectionExceptionsTile(), ], ); } } class _TrackingProtectionExceptionsTile extends StatelessWidget { const _TrackingProtectionExceptionsTile(); @override Widget build(BuildContext context) { return ListTile( leading: const Icon(MdiIcons.shieldOffOutline), title: const Text('Tracking Protection Exceptions'), subtitle: const Text('Sites where tracking protection is disabled'), trailing: const Icon(Icons.chevron_right), onTap: () async { await TrackingProtectionExceptionsRoute().push(context); }, ); } } class _ConnectionSecuritySection extends StatelessWidget { const _ConnectionSecuritySection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Connection Security'), _HttpsOnlyModeSection(), _DnsTile(), ], ); } } class _PrivacySignalsSection extends StatelessWidget { const _PrivacySignalsSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Privacy Signals & Modes'), _IncognitoModeSection(), _GlobalPrivacyControlTile(), ], ); } } class _DataManagementSection extends StatelessWidget { const _DataManagementSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Data Management'), _DeleteBrowsingDataTile(), _AutoClearHistorySection(), _AutoClearUnassignedTabsSection(), ], ); } } class _AdvancedSecuritySection extends StatelessWidget { const _AdvancedSecuritySection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Advanced Security'), _WebEngineHardeningTile(), _FissionEnabledTile(), _ExtensionsWebAPIEnabledTile(), ], ); } } class _IncognitoModeSection extends HookConsumerWidget { const _IncognitoModeSection(); @override Widget build(BuildContext context, WidgetRef ref) { final deleteBrowsingDataOnQuit = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.deleteBrowsingDataOnQuit, ), ); return Column( children: [ SwitchListTile.adaptive( title: const Text('Incognito Mode'), subtitle: const Text( 'Deletes selected browsing data upon app restart for enhanced privacy.', ), secondary: const Icon(MdiIcons.incognito), value: deleteBrowsingDataOnQuit != null, onChanged: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => value ? currentSettings.copyWith.deleteBrowsingDataOnQuit({}) : currentSettings.copyWith.deleteBrowsingDataOnQuit(null), ); }, ), if (deleteBrowsingDataOnQuit != null) _DeleteBrowsingDataTypes(selectedTypes: deleteBrowsingDataOnQuit), ], ); } } class _DeleteBrowsingDataTypes extends HookConsumerWidget { final Set selectedTypes; const _DeleteBrowsingDataTypes({required this.selectedTypes}); @override Widget build(BuildContext context, WidgetRef ref) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( children: [ for (final type in DeleteBrowsingDataType.values) CheckboxListTile.adaptive( value: selectedTypes.contains(type), controlAffinity: ListTileControlAffinity.leading, title: Text(type.title), subtitle: type.description.mapNotNull( (description) => Text(description), ), onChanged: (value) async { final notifier = ref.read( saveGeneralSettingsControllerProvider.notifier, ); if (value == true) { await notifier.save( (currentSettings) => currentSettings.copyWith.deleteBrowsingDataOnQuit({ ...currentSettings.deleteBrowsingDataOnQuit!, type, }), ); } else { await notifier.save( (currentSettings) => currentSettings.copyWith.deleteBrowsingDataOnQuit( {...currentSettings.deleteBrowsingDataOnQuit!} ..remove(type), ), ); } }, ), ], ), ); } } class _DeleteBrowsingDataTile extends StatelessWidget { const _DeleteBrowsingDataTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Delete Browsing Data'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.databaseRemove), trailing: const Icon(Icons.chevron_right), onTap: () async { await showDeleteDataDialog(context); }, ); } } class _AutoClearHistorySection extends HookConsumerWidget { const _AutoClearHistorySection(); @override Widget build(BuildContext context, WidgetRef ref) { final historyAutoCleanInterval = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.historyAutoCleanInterval, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Auto-Clear History'), subtitle: Text( 'Automatically delete browsing history older than the selected time period', ), leading: Icon(MdiIcons.deleteClock), contentPadding: EdgeInsets.zero, ), Padding( padding: const EdgeInsets.only(left: 40.0), child: DropdownMenu( initialSelection: historyAutoCleanInterval, inputDecorationTheme: InputDecorationTheme( prefixIconConstraints: BoxConstraints.tight( const Size.square(24), ), ), width: double.infinity, dropdownMenuEntries: const [ DropdownMenuEntry(value: Duration.zero, label: 'Never'), DropdownMenuEntry(value: Duration(days: 1), label: '1 Day'), DropdownMenuEntry(value: Duration(days: 3), label: '3 Days'), DropdownMenuEntry(value: Duration(days: 7), label: '1 Week'), DropdownMenuEntry(value: Duration(days: 14), label: '2 Weeks'), DropdownMenuEntry(value: Duration(days: 30), label: '1 Month'), DropdownMenuEntry(value: Duration(days: 90), label: '3 Months'), ], onSelected: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .historyAutoCleanInterval(value ?? Duration.zero), ); }, ), ), ], ), ); } } class _AutoClearUnassignedTabsSection extends HookConsumerWidget { const _AutoClearUnassignedTabsSection(); @override Widget build(BuildContext context, WidgetRef ref) { final unassignedTabsAutoCleanInterval = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.unassignedTabsAutoCleanInterval, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Auto-Clear Unassigned Tabs'), subtitle: Text( 'Automatically close unassigned tabs older than the selected time period', ), leading: Icon(MdiIcons.tabRemove), contentPadding: EdgeInsets.zero, ), Padding( padding: const EdgeInsets.only(left: 40.0), child: DropdownMenu( initialSelection: unassignedTabsAutoCleanInterval, inputDecorationTheme: InputDecorationTheme( prefixIconConstraints: BoxConstraints.tight( const Size.square(24), ), ), width: double.infinity, dropdownMenuEntries: const [ DropdownMenuEntry(value: Duration.zero, label: 'Never'), DropdownMenuEntry(value: Duration(days: 1), label: '1 Day'), DropdownMenuEntry(value: Duration(days: 3), label: '3 Days'), DropdownMenuEntry(value: Duration(days: 7), label: '1 Week'), DropdownMenuEntry(value: Duration(days: 14), label: '2 Weeks'), DropdownMenuEntry(value: Duration(days: 30), label: '1 Month'), DropdownMenuEntry(value: Duration(days: 90), label: '3 Months'), ], onSelected: (value) async { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .unassignedTabsAutoCleanInterval( value ?? Duration.zero, ), ); }, ), ), ], ), ); } } class _GlobalPrivacyControlTile extends HookConsumerWidget { const _GlobalPrivacyControlTile(); @override Widget build(BuildContext context, WidgetRef ref) { final globalPrivacyControlEnabled = ref.watch( engineSettingsWithDefaultsProvider.select( (s) => s.globalPrivacyControlEnabled, ), ); return SwitchListTile.adaptive( title: const Text('Global Privacy Control (GPC)'), secondary: const Icon(MdiIcons.incognitoCircleOff), value: globalPrivacyControlEnabled, onChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.globalPrivacyControlEnabled(value), ); }, ); } } class _HttpsOnlyModeSection extends HookConsumerWidget { const _HttpsOnlyModeSection(); @override Widget build(BuildContext context, WidgetRef ref) { final httpsOnlyMode = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.httpsOnlyMode), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Block insecure HTTP connections'), leading: Icon(MdiIcons.lockOpen), contentPadding: EdgeInsets.zero, ), Center( child: SegmentedButton( segments: const [ ButtonSegment( value: HttpsOnlyMode.disabled, label: Text('Disabled'), ), ButtonSegment( value: HttpsOnlyMode.enabled, label: Text('Enabled'), ), ButtonSegment( value: HttpsOnlyMode.privateOnly, label: Text('Private mode only'), ), ], selected: {httpsOnlyMode}, onSelectionChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.httpsOnlyMode(value.first), ); }, ), ), ], ), ); } } class _DnsTile extends StatelessWidget { const _DnsTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('DNS over HTTPS'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.dns), trailing: const Icon(Icons.chevron_right), onTap: () async { await DohSettingsRoute().push(context); }, ); } } class _EnhancedTrackingProtectionSection extends HookConsumerWidget { const _EnhancedTrackingProtectionSection(); @override Widget build(BuildContext context, WidgetRef ref) { final trackingProtectionPolicy = ref.watch( engineSettingsWithDefaultsProvider.select( (s) => s.trackingProtectionPolicy, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Enhanced Tracking Protection'), leading: Icon(MdiIcons.incognitoCircleOff), contentPadding: EdgeInsets.zero, ), RadioGroup( groupValue: trackingProtectionPolicy, onChanged: (value) async { if (value != null) { // Save the policy change await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .trackingProtectionPolicy(value), ); } // Navigate to custom settings screen when Custom is selected if (value == TrackingProtectionPolicy.custom || (value == null && trackingProtectionPolicy == TrackingProtectionPolicy.custom)) { if (context.mounted) { await CustomTrackingProtectionRoute().push(context); } } }, child: const Column( children: [ RadioListTile.adaptive( value: TrackingProtectionPolicy.none, title: Text('Disabled'), ), RadioListTile.adaptive( value: TrackingProtectionPolicy.recommended, title: Text('Standard'), subtitle: Text( 'Pages will load normally, but block fewer trackers.', ), ), RadioListTile.adaptive( value: TrackingProtectionPolicy.strict, title: Text('Strict'), subtitle: Text( 'Stronger tracking protection and faster performance, but some sites may not work properly.', ), ), RadioListTile.adaptive( value: TrackingProtectionPolicy.custom, toggleable: true, title: Text('Custom'), subtitle: Text('Choose which trackers and scripts to block.'), secondary: Icon(Icons.chevron_right), ), ], ), ), ], ), ); } } class _BounceTrackingProtectionTile extends HookConsumerWidget { const _BounceTrackingProtectionTile(); @override Widget build(BuildContext context, WidgetRef ref) { final bounceTrackingProtectionMode = ref.watch( engineSettingsWithDefaultsProvider.select( (s) => s.contentBlocking.bounceTrackingProtectionMode, ), ); final isEnabled = switch (bounceTrackingProtectionMode) { BounceTrackingProtectionMode.disabled => false, BounceTrackingProtectionMode.enabled => true, BounceTrackingProtectionMode.enabledStandby => false, BounceTrackingProtectionMode.enabledDryRun => false, }; return SwitchListTile.adaptive( title: const Text('Bounce Tracking Protection'), subtitle: const Text( 'Blocks redirect trackers that collect data through intermediate URL redirects between websites', ), secondary: const Icon(MdiIcons.securityNetwork), value: isEnabled, onChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.bounceTrackingProtectionMode( value ? BounceTrackingProtectionMode.enabled : BounceTrackingProtectionMode.disabled, ), ); if (context.mounted) { await _showRestartDialog(context, ref); } }, ); } } class _QueryParameterStrippingSection extends HookConsumerWidget { const _QueryParameterStrippingSection(); @override Widget build(BuildContext context, WidgetRef ref) { final queryParameterStripping = ref.watch( engineSettingsWithDefaultsProvider.select( (s) => s.queryParameterStripping, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const ListTile( title: Text('Query Parameter Stripping'), subtitle: Text( 'Removes tracking parameters from URLs to prevent cross-site user tracking', ), leading: Icon(MdiIcons.closeNetwork), contentPadding: EdgeInsets.zero, ), Center( child: SegmentedButton( segments: const [ ButtonSegment( value: QueryParameterStripping.disabled, label: Text('Disabled'), ), ButtonSegment( value: QueryParameterStripping.enabled, label: Text('Enabled'), ), ButtonSegment( value: QueryParameterStripping.privateOnly, label: Text('Private mode only'), ), ], selected: {queryParameterStripping}, onSelectionChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith .queryParameterStripping(value.first), ); }, ), ), ], ), ); } } class _WebEngineHardeningTile extends StatelessWidget { const _WebEngineHardeningTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Web Engine Hardening'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.shieldLock), trailing: const Icon(Icons.chevron_right), onTap: () async { await WebEngineHardeningRoute().push(context); }, ); } } class _FissionEnabledTile extends HookConsumerWidget { const _FissionEnabledTile(); @override Widget build(BuildContext context, WidgetRef ref) { final fissionEnabled = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.fissionEnabled), ); return SwitchListTile.adaptive( title: const Text('Fission (Site Isolation)'), subtitle: const Text( 'Isolates each site into a separate OS process for improved security. Requires app restart.', ), secondary: const Icon(MdiIcons.shieldHalfFull), value: fissionEnabled, onChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.fissionEnabled(value), ); if (context.mounted) { await _showRestartDialog(context, ref); } }, ); } } class _ExtensionsWebAPIEnabledTile extends HookConsumerWidget { const _ExtensionsWebAPIEnabledTile(); @override Widget build(BuildContext context, WidgetRef ref) { final extensionsWebAPIEnabled = ref.watch( engineSettingsWithDefaultsProvider.select( (s) => s.extensionsWebAPIEnabled, ), ); return SwitchListTile.adaptive( title: const Text('Extensions Web API'), subtitle: const Text( 'Enable mozAddonManager API exposure for web content and extension pages. Requires app restart.', ), secondary: const Icon(Icons.extension), value: extensionsWebAPIEnabled, onChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.extensionsWebAPIEnabled(value), ); if (context.mounted) { await _showRestartDialog(context, ref); } }, ); } } Future _showRestartDialog(BuildContext context, WidgetRef ref) async { final result = await showQuitBrowserDialog(context); if (result == true && context.mounted) { await exitApp(ProviderScope.containerOf(context)); } } class _NetworkProtectionSection extends StatelessWidget { const _NetworkProtectionSection(); @override Widget build(BuildContext context) { return const Column( children: [ SettingSection(name: 'Network Protection'), _LnaEnabledTile(), _LnaBlockingTile(), _LnaBlockTrackersTile(), ], ); } } class _BrowserLanguagesTile extends StatelessWidget { const _BrowserLanguagesTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Browser Languages'), subtitle: const Text( 'Configure language preferences exposed to websites', ), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(Icons.translate), trailing: const Icon(Icons.chevron_right), onTap: () async { await LocaleSettingsRoute().push(context); }, ); } } class _FingerprintProtectionTile extends StatelessWidget { const _FingerprintProtectionTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Fingerprint Protection'), subtitle: const Text('Granular control over browser fingerprinting'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.fingerprint), trailing: const Icon(Icons.chevron_right), onTap: () async { await FingerprintSettingsRoute().push(context); }, ); } } class _ResistFingerprintingTile extends StatelessWidget { const _ResistFingerprintingTile(); @override Widget build(BuildContext context) { return ListTile( title: const Text('Resist Fingerprinting'), subtitle: const Text('Advanced fingerprinting protection hardening'), contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16.0, ), leading: const Icon(MdiIcons.shieldLock), trailing: const Icon(Icons.chevron_right), onTap: () async { await const WebEngineHardeningGroupRoute( group: 'Resist Fingerprinting', ).push(context); }, ); } } class _LnaEnabledTile extends HookConsumerWidget { const _LnaEnabledTile(); @override Widget build(BuildContext context, WidgetRef ref) { final lnaEnabled = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled), ); return SwitchListTile.adaptive( title: const Text('Local Network Access'), subtitle: const Text('Enable local network and device access blocking'), secondary: const Icon(MdiIcons.lanDisconnect), value: lnaEnabled ?? false, onChanged: (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.lnaEnabled(value), ); }, ); } } class _LnaBlockingTile extends HookConsumerWidget { const _LnaBlockingTile(); @override Widget build(BuildContext context, WidgetRef ref) { final lnaEnabled = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled), ); final lnaBlocking = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.lnaBlocking), ); return SwitchListTile.adaptive( title: const Text('Block Local Network Requests'), subtitle: const Text( 'Block web page requests to local network addresses', ), secondary: const Icon(MdiIcons.shieldLockOpen), value: lnaBlocking ?? false, onChanged: lnaEnabled == true ? (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.lnaBlocking(value), ); } : null, ); } } class _LnaBlockTrackersTile extends HookConsumerWidget { const _LnaBlockTrackersTile(); @override Widget build(BuildContext context, WidgetRef ref) { final lnaEnabled = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.lnaEnabled), ); final lnaBlockTrackers = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.lnaBlockTrackers), ); return SwitchListTile.adaptive( title: const Text('Block Local Network Trackers'), subtitle: const Text( 'Block trackers from accessing local network resources', ), secondary: const Icon(MdiIcons.shieldBug), value: lnaBlockTrackers ?? false, onChanged: lnaEnabled == true ? (value) async { await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.lnaBlockTrackers(value), ); } : null, ); } }