From 2741876ed58f6979744770668ea5fac01c2e5a1f Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 8 Jan 2026 18:42:03 +0100 Subject: [PATCH] added support for tor entry/exit countries --- .../tor/domain/services/tor_proxy.dart | 6 + .../tor/domain/services/tor_proxy.g.dart | 2 +- .../tor/presentation/screens/tor_proxy.dart | 253 +++++++++++++++--- .../user/data/models/tor_settings.dart | 8 + .../user/data/models/tor_settings.g.dart | 28 ++ .../domain/repositories/tor_settings.dart | 8 + .../domain/repositories/tor_settings.g.dart | 2 +- app/lib/main.dart | 3 + app/pubspec.yaml | 1 + 9 files changed, 276 insertions(+), 35 deletions(-) diff --git a/app/lib/features/tor/domain/services/tor_proxy.dart b/app/lib/features/tor/domain/services/tor_proxy.dart index a3476211..ebcfa279 100644 --- a/app/lib/features/tor/domain/services/tor_proxy.dart +++ b/app/lib/features/tor/domain/services/tor_proxy.dart @@ -136,6 +136,8 @@ class TorProxyService extends _$TorProxyService { null => TransportType.none, }, bridgeLines: setting?.bridge.bridges ?? [], + entryNodeCountries: torSettings.entryNodeCountry?.toLowerCase(), + exitNodeCountries: torSettings.exitNodeCountry?.toLowerCase(), ); await _tor.start(config); @@ -155,6 +157,10 @@ class TorProxyService extends _$TorProxyService { await _tor.stop(); } + Future requestNewIdentity() async { + await _tor.requestNewIdentity(); + } + @override Stream build() { _statusSyncController = StreamController(); diff --git a/app/lib/features/tor/domain/services/tor_proxy.g.dart b/app/lib/features/tor/domain/services/tor_proxy.g.dart index 8507a073..eda95485 100644 --- a/app/lib/features/tor/domain/services/tor_proxy.g.dart +++ b/app/lib/features/tor/domain/services/tor_proxy.g.dart @@ -33,7 +33,7 @@ final class TorProxyServiceProvider TorProxyService create() => TorProxyService(); } -String _$torProxyServiceHash() => r'be69327bdef8feaa2a43bd83f20168988dcdd228'; +String _$torProxyServiceHash() => r'0bf00b61bf7e739fe8c77696cf1b2a151d137671'; abstract class _$TorProxyService extends $StreamNotifier { Stream build(); diff --git a/app/lib/features/tor/presentation/screens/tor_proxy.dart b/app/lib/features/tor/presentation/screens/tor_proxy.dart index 90e9b6dd..bdc02967 100644 --- a/app/lib/features/tor/presentation/screens/tor_proxy.dart +++ b/app/lib/features/tor/presentation/screens/tor_proxy.dart @@ -17,11 +17,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:country_codes/country_codes.dart'; +import 'package:country_flags/country_flags.dart'; import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; @@ -29,6 +32,7 @@ import 'package:weblibre/features/user/data/models/tor_settings.dart'; import 'package:weblibre/features/user/domain/repositories/tor_settings.dart'; import 'package:weblibre/presentation/hooks/on_initialization.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; +import 'package:weblibre/utils/ui_helper.dart'; class TorProxyScreen extends HookConsumerWidget { const TorProxyScreen(); @@ -89,6 +93,39 @@ class TorProxyScreen extends HookConsumerWidget { } }); + final countryDropdownEntries = useMemoized( + () => [ + const DropdownMenuEntry( + value: null, + label: 'Automatic', + leadingIcon: Icon(MdiIcons.lightbulbAuto), + ), + ...CountryCodes.countryCodes().map((country) { + final label = + country.localizedName ?? + country.name ?? + country.alpha2Code ?? + country.countryCode ?? + 'Unnamed Country'; + + return DropdownMenuEntry( + value: country.alpha2Code, + label: label, + leadingIcon: country.alpha2Code.mapNotNull( + (code) => CountryFlag.fromCountryCode( + code, + theme: const ImageTheme(width: 24, height: 16), + ), + ), + labelWidget: Text( + label, + style: const TextStyle(color: Colors.white), + ), + ); + }), + ], + ); + return Scaffold( appBar: AppBar(title: const Text('Tor™ Proxy')), body: Theme( @@ -139,39 +176,72 @@ class TorProxyScreen extends HookConsumerWidget { border: Border.all(color: Colors.white), borderRadius: BorderRadius.circular(16), ), - child: SwitchListTile.adaptive( - inactiveThumbColor: Colors.white, - activeThumbColor: AppColors.torActiveGreen, - thumbIcon: WidgetStateProperty.resolveWith(( - Set states, - ) { - if (states.contains(WidgetState.selected)) { - return const Icon(MdiIcons.axisArrowLock); - } - return null; // Use the default color. - }), - value: torPendingRequest.value ?? torIsRunning, - title: const Text('Tor™ Proxy'), - secondary: const Icon(MdiIcons.power), - onChanged: torIsBusy - ? null - : (value) async { - if (value) { - torPendingRequest.value = true; + child: Column( + children: [ + SwitchListTile.adaptive( + inactiveThumbColor: Colors.white, + activeThumbColor: AppColors.torActiveGreen, + thumbIcon: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return const Icon(MdiIcons.axisArrowLock); + } + return null; // Use the default color. + }), + value: torPendingRequest.value ?? torIsRunning, + title: const Text('Tor™ Proxy'), + secondary: const Icon(MdiIcons.power), + onChanged: torIsBusy + ? null + : (value) async { + if (value) { + torPendingRequest.value = true; - await ref - .read(torProxyServiceProvider.notifier) - .startOrReconfigure( - reconfigureIfRunning: false, - ); - } else { - torPendingRequest.value = false; + await ref + .read(torProxyServiceProvider.notifier) + .startOrReconfigure( + reconfigureIfRunning: false, + ); + } else { + torPendingRequest.value = false; - await ref - .read(torProxyServiceProvider.notifier) - .disconnect(); - } - }, + await ref + .read(torProxyServiceProvider.notifier) + .disconnect(); + } + }, + ), + Padding( + padding: const EdgeInsets.only( + right: 16.0, + left: 16, + bottom: 16, + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: + torIsRunning && torIsBootstrapped && !torIsBusy + ? () async { + await ref + .read(torProxyServiceProvider.notifier) + .requestNewIdentity(); + + if (context.mounted) { + showInfoMessage( + context, + 'Requesting new Tor identity...', + ); + } + } + : null, + icon: const Icon(MdiIcons.refresh), + label: const Text('Request New Identity'), + ), + ), + ), + ], ), ), const SizedBox(height: 16), @@ -447,6 +517,123 @@ class TorProxyScreen extends HookConsumerWidget { ), ), ], + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.only(left: 24.0), + child: Text( + 'Country Restrictions', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 24.0, + top: 8.0, + ), + child: DropdownMenu( + initialSelection: + torSettings.entryNodeCountry, + menuHeight: 400, + requestFocusOnTap: true, + label: const Text('Entry Country'), + textStyle: const TextStyle( + color: Colors.white, + ), + inputDecorationTheme: + const InputDecorationTheme( + labelStyle: TextStyle( + color: Colors.white, + ), + iconColor: Colors.white, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Colors.white, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Colors.white, + ), + ), + ), + // menuStyle: MenuStyle( + // backgroundColor: + // WidgetStateProperty.all( + // AppColors.torPurple, + // ), + // ), + dropdownMenuEntries: countryDropdownEntries, + onSelected: (value) async { + await ref + .read( + saveTorSettingsControllerProvider + .notifier, + ) + .save( + (currentSettings) => currentSettings + .copyWith + .entryNodeCountry(value), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 24.0, + top: 8.0, + ), + child: DropdownMenu( + initialSelection: torSettings.exitNodeCountry, + menuHeight: 400, + requestFocusOnTap: true, + label: const Text('Exit Country'), + textStyle: const TextStyle( + color: Colors.white, + ), + inputDecorationTheme: + const InputDecorationTheme( + labelStyle: TextStyle( + color: Colors.white, + ), + iconColor: Colors.white, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Colors.white, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Colors.white, + ), + ), + ), + // menuStyle: MenuStyle( + // backgroundColor: + // WidgetStateProperty.all( + // AppColors.torPurple, + // ), + // ), + dropdownMenuEntries: countryDropdownEntries, + onSelected: (value) async { + await ref + .read( + saveTorSettingsControllerProvider + .notifier, + ) + .save( + (currentSettings) => currentSettings + .copyWith + .exitNodeCountry(value), + ); + }, + ), + ), ], ); }, @@ -503,8 +690,8 @@ class TorProxyScreen extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.only( - right: 8.0, - left: 8.0, + right: 12.0, + left: 12.0, bottom: 8.0, ), child: Text( diff --git a/app/lib/features/user/data/models/tor_settings.dart b/app/lib/features/user/data/models/tor_settings.dart index 7bd4c83d..acad5343 100644 --- a/app/lib/features/user/data/models/tor_settings.dart +++ b/app/lib/features/user/data/models/tor_settings.dart @@ -35,6 +35,8 @@ class TorSettings with FastEquatable { final TorConnectionConfig config; final bool requireBridge; final bool fetchRemoteBridges; + final String? entryNodeCountry; + final String? exitNodeCountry; TorSettings({ required this.proxyRegularTabsMode, @@ -42,6 +44,8 @@ class TorSettings with FastEquatable { required this.config, required this.requireBridge, required this.fetchRemoteBridges, + required this.entryNodeCountry, + required this.exitNodeCountry, }); TorSettings.withDefaults({ @@ -50,6 +54,8 @@ class TorSettings with FastEquatable { TorConnectionConfig? config, bool? requireBridge, bool? fetchRemoteBridges, + this.entryNodeCountry, + this.exitNodeCountry, }) : proxyRegularTabsMode = proxyRegularTabsMode ?? TorRegularTabProxyMode.container, proxyPrivateTabsTor = proxyPrivateTabsTor ?? false, @@ -69,5 +75,7 @@ class TorSettings with FastEquatable { config, requireBridge, fetchRemoteBridges, + entryNodeCountry, + exitNodeCountry, ]; } diff --git a/app/lib/features/user/data/models/tor_settings.g.dart b/app/lib/features/user/data/models/tor_settings.g.dart index 22a7a6e2..dbc65f0a 100644 --- a/app/lib/features/user/data/models/tor_settings.g.dart +++ b/app/lib/features/user/data/models/tor_settings.g.dart @@ -17,6 +17,10 @@ abstract class _$TorSettingsCWProxy { TorSettings fetchRemoteBridges(bool fetchRemoteBridges); + TorSettings entryNodeCountry(String? entryNodeCountry); + + TorSettings exitNodeCountry(String? exitNodeCountry); + /// 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 `TorSettings(...).copyWith.fieldName(value)`. /// @@ -30,6 +34,8 @@ abstract class _$TorSettingsCWProxy { TorConnectionConfig config, bool requireBridge, bool fetchRemoteBridges, + String? entryNodeCountry, + String? exitNodeCountry, }); } @@ -60,6 +66,14 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy { TorSettings fetchRemoteBridges(bool fetchRemoteBridges) => call(fetchRemoteBridges: fetchRemoteBridges); + @override + TorSettings entryNodeCountry(String? entryNodeCountry) => + call(entryNodeCountry: entryNodeCountry); + + @override + TorSettings exitNodeCountry(String? exitNodeCountry) => + call(exitNodeCountry: exitNodeCountry); + @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 `TorSettings(...).copyWith.fieldName(value)`. @@ -74,6 +88,8 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy { Object? config = const $CopyWithPlaceholder(), Object? requireBridge = const $CopyWithPlaceholder(), Object? fetchRemoteBridges = const $CopyWithPlaceholder(), + Object? entryNodeCountry = const $CopyWithPlaceholder(), + Object? exitNodeCountry = const $CopyWithPlaceholder(), }) { return TorSettings( proxyRegularTabsMode: @@ -103,6 +119,14 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy { ? _value.fetchRemoteBridges // ignore: cast_nullable_to_non_nullable : fetchRemoteBridges as bool, + entryNodeCountry: entryNodeCountry == const $CopyWithPlaceholder() + ? _value.entryNodeCountry + // ignore: cast_nullable_to_non_nullable + : entryNodeCountry as String?, + exitNodeCountry: exitNodeCountry == const $CopyWithPlaceholder() + ? _value.exitNodeCountry + // ignore: cast_nullable_to_non_nullable + : exitNodeCountry as String?, ); } } @@ -128,6 +152,8 @@ TorSettings _$TorSettingsFromJson(Map json) => config: $enumDecodeNullable(_$TorConnectionConfigEnumMap, json['config']), requireBridge: json['requireBridge'] as bool?, fetchRemoteBridges: json['fetchRemoteBridges'] as bool?, + entryNodeCountry: json['entryNodeCountry'] as String?, + exitNodeCountry: json['exitNodeCountry'] as String?, ); Map _$TorSettingsToJson(TorSettings instance) => @@ -138,6 +164,8 @@ Map _$TorSettingsToJson(TorSettings instance) => 'config': _$TorConnectionConfigEnumMap[instance.config]!, 'requireBridge': instance.requireBridge, 'fetchRemoteBridges': instance.fetchRemoteBridges, + 'entryNodeCountry': instance.entryNodeCountry, + 'exitNodeCountry': instance.exitNodeCountry, }; const _$TorRegularTabProxyModeEnumMap = { diff --git a/app/lib/features/user/domain/repositories/tor_settings.dart b/app/lib/features/user/domain/repositories/tor_settings.dart index 438d3748..7c2bcbb2 100644 --- a/app/lib/features/user/domain/repositories/tor_settings.dart +++ b/app/lib/features/user/domain/repositories/tor_settings.dart @@ -55,6 +55,14 @@ class TorSettingsRepository extends _$TorSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'entryNodeCountry': settings['entryNodeCountry']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'exitNodeCountry': settings['exitNodeCountry']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), }); } diff --git a/app/lib/features/user/domain/repositories/tor_settings.g.dart b/app/lib/features/user/domain/repositories/tor_settings.g.dart index 59a8f49c..fede70fb 100644 --- a/app/lib/features/user/domain/repositories/tor_settings.g.dart +++ b/app/lib/features/user/domain/repositories/tor_settings.g.dart @@ -34,7 +34,7 @@ final class TorSettingsRepositoryProvider } String _$torSettingsRepositoryHash() => - r'341730ed58b49ab3d721583e0f2d5ac650018918'; + r'f771f23f17903bd192b24604bab522fb20571ffd'; abstract class _$TorSettingsRepository extends $StreamNotifier { Stream build(); diff --git a/app/lib/main.dart b/app/lib/main.dart index e0c1384c..e270487e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -20,6 +20,7 @@ import 'dart:developer'; import 'package:background_fetch/background_fetch.dart'; +import 'package:country_codes/country_codes.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -74,6 +75,8 @@ class _MainWidget extends HookConsumerWidget { ); useOnInitialization(() async { + await CountryCodes.init(); + final engineSettings = await ref .read(engineSettingsRepositoryProvider.notifier) .fetchSettings(); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8200ea9f..874f6c44 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: collection: ^1.19.1 convert: ^3.1.2 copy_with_extension: ^10.0.1 + country_codes: ^3.3.0 country_flags: ^4.1.1 cryptography_flutter: ^2.3.4 drift: ^2.30.0