diff --git a/ostp-flutter/lib/models/ostp_profile.dart b/ostp-flutter/lib/models/ostp_profile.dart new file mode 100644 index 0000000..d1e130b --- /dev/null +++ b/ostp-flutter/lib/models/ostp_profile.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +/// A saved server profile. Field shape mirrors the desktop GUI's profile +/// object (ostp-gui/src/main.js) 1:1 — server/key/transport/tcp_fragmentation/ +/// frag_chunk/frag_sleep/junk_pc/junk_ps — so behavior matches across +/// platforms. `wss` was dropped: the core no longer supports TLS-mimicry +/// transports (only plain UDP / UoT), so there is nothing left to carry it. +class OstpProfile { + String id; + String name; + String serverAddr; + String accessKey; + String transportMode; // 'udp' | 'uot' + String stealthSni; + bool active; + + // Junk packets + TCP fragmentation — per-profile, exactly like ostp-gui's + // profile editor. Defaults match ostp_client::config::TransportConfig's + // own defaults (frag_chunk=2, frag_sleep=2, junk_pc=[2,5], junk_ps=[100,1000]). + bool tcpFragmentation; + int fragChunk; + int fragSleep; + int junkPcMin; + int junkPcMax; + int junkPsMin; + int junkPsMax; + + OstpProfile({ + required this.id, + required this.name, + required this.serverAddr, + required this.accessKey, + this.transportMode = 'udp', + this.stealthSni = '', + this.active = false, + this.tcpFragmentation = false, + this.fragChunk = 2, + this.fragSleep = 2, + this.junkPcMin = 2, + this.junkPcMax = 5, + this.junkPsMin = 100, + this.junkPsMax = 1000, + }); + + Map toJson() { + return { + 'id': id, + 'name': name, + 'serverAddr': serverAddr, + 'accessKey': accessKey, + 'transportMode': transportMode, + 'stealthSni': stealthSni, + 'active': active, + 'tcpFragmentation': tcpFragmentation, + 'fragChunk': fragChunk, + 'fragSleep': fragSleep, + 'junkPcMin': junkPcMin, + 'junkPcMax': junkPcMax, + 'junkPsMin': junkPsMin, + 'junkPsMax': junkPsMax, + }; + } + + factory OstpProfile.fromJson(Map json) { + return OstpProfile( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? 'Unnamed Profile', + serverAddr: json['serverAddr'] as String? ?? '', + accessKey: json['accessKey'] as String? ?? '', + transportMode: json['transportMode'] as String? ?? 'udp', + stealthSni: json['stealthSni'] as String? ?? '', + active: json['active'] as bool? ?? false, + tcpFragmentation: json['tcpFragmentation'] as bool? ?? false, + fragChunk: json['fragChunk'] as int? ?? 2, + fragSleep: json['fragSleep'] as int? ?? 2, + junkPcMin: json['junkPcMin'] as int? ?? 2, + junkPcMax: json['junkPcMax'] as int? ?? 5, + junkPsMin: json['junkPsMin'] as int? ?? 100, + junkPsMax: json['junkPsMax'] as int? ?? 1000, + ); + } +} + +List decodeProfiles(String? json) { + if (json == null || json.isEmpty) return []; + try { + final List decoded = jsonDecode(json); + return decoded.map((e) => OstpProfile.fromJson(e)).toList(); + } catch (_) { + return []; + } +} + +String encodeProfiles(List profiles) => + jsonEncode(profiles.map((e) => e.toJson()).toList()); diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart index d0729f8..31c5548 100644 --- a/ostp-flutter/lib/ui/home_screen.dart +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -1,16 +1,12 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; import '../models/connection_state_enum.dart'; +import '../models/ostp_profile.dart'; import 'settings_screen.dart'; -import 'logs_screen.dart'; -import 'app_routing_screen.dart'; -import 'qr_scanner_screen.dart'; class HomeScreen extends StatefulWidget { final SharedPreferences prefs; @@ -22,15 +18,17 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with TickerProviderStateMixin { static const platform = MethodChannel('com.ospab.ostp/vpn'); - + ConnectionStateEnum _state = ConnectionStateEnum.disconnected; Timer? _pollTimer; Timer? _uptimeTimer; int _uptimeSecs = 0; - - String _serverAddr = '127.0.0.1:443'; - String _accessKey = 'default_key'; - + + // Single active profile — the core only ever connects to one server at a + // time (no multi-server/urltest failover since the 0.4.x flat config), + // matching how the desktop GUI picks exactly one profile as `activeId`. + OstpProfile? _activeProfile; + String _download = '0 B'; String _upload = '0 B'; @@ -67,40 +65,45 @@ class _HomeScreenState extends State with TickerProviderStateMixin { debugPrint("Failed to check initial state: $e"); } } - + void _loadSettings() { setState(() { - _serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; - _accessKey = widget.prefs.getString('access_key') ?? ''; + final profiles = decodeProfiles(widget.prefs.getString('profiles_json')); + // Single-select: if more than one is somehow marked active (shouldn't + // happen — the editor enforces exclusivity — but don't crash on stale data). + final actives = profiles.where((p) => p.active).toList(); + _activeProfile = actives.isNotEmpty ? actives.first : null; }); _updateLatestConfigJson(); } - void _updateLatestConfigJson() { - + /// Builds the exact JSON the native core (ostp-jni) deserializes as + /// `ostp_client::config::ClientConfig`. Field names/nesting must match that + /// struct precisely — unknown keys are silently ignored by serde, so a typo + /// here doesn't fail loudly, it just quietly does nothing. + Map _buildConfigMap() { + final p = _activeProfile; final exDomains = widget.prefs.getString('ex_domains') ?? ''; final exIps = widget.prefs.getString('ex_ips') ?? ''; final exProcesses = widget.prefs.getString('ex_processes') ?? ''; final debugMode = widget.prefs.getBool('debug_mode') ?? false; - final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; final mtu = widget.prefs.getString('mtu') ?? '1140'; final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; final dnsServer = widget.prefs.getString('dns_server'); final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; - final tunStack = 'ostp'; + const tunStack = 'ostp'; final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; - final configMap = { + + return { "mode": "client", "debug": debugMode, "ostp": { - "server_addr": _serverAddr, + "server_addr": p?.serverAddr ?? '', "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, + "access_key": p?.accessKey ?? '', "handshake_timeout_ms": 10000, "io_timeout_ms": 5000, "mtu": int.tryParse(mtu) ?? 1140, @@ -109,18 +112,21 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "bind_addr": localBind, "connect_timeout_ms": 15000, }, + // Junk packets + TCP fragmentation are per-profile settings — same + // shape as the desktop GUI's profile object — not global toggles. "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, + "mode": p?.transportMode ?? 'udp', + "stealth_sni": (p?.stealthSni.isNotEmpty ?? false) ? p!.stealthSni : 'vk.com', + "tcp_fragmentation": p?.tcpFragmentation ?? false, + "frag_chunk": p?.fragChunk ?? 2, + "frag_sleep": p?.fragSleep ?? 2, + "junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5], + "junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000], }, "multiplex": { "enabled": muxEnabled, "sessions": int.tryParse(muxSessions) ?? 2, }, - "tun": { - "enable": true, - "stack": tunStack - }, "exclusions": { "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), @@ -131,12 +137,14 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "packages": appRoutingPackages, }, "dns_server": effectiveDnsServer, - "tun_stack": tunStack + "tun_stack": tunStack, }; + } + + void _updateLatestConfigJson() { + final configMap = _buildConfigMap(); widget.prefs.setString('latest_config_json', jsonEncode(configMap)); - platform.invokeMethod('saveConfig', { - "configJson": jsonEncode(configMap) - }); + platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)}); } @override @@ -150,87 +158,27 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Future _toggleConnection() async { if (_state == ConnectionStateEnum.disconnected) { - if (_serverAddr.isEmpty || _accessKey.isEmpty) { + if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key in Settings')), + const SnackBar(content: Text('Please select or add a profile in Settings')), ); return; } - + setState(() { _state = ConnectionStateEnum.connecting; }); _pulseController.repeat(reverse: true); _spinController.repeat(); - final dnsServer = widget.prefs.getString('dns_server'); - final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; - final exDomains = widget.prefs.getString('ex_domains') ?? ''; - final exIps = widget.prefs.getString('ex_ips') ?? ''; - final exProcesses = widget.prefs.getString('ex_processes') ?? ''; - final debugMode = widget.prefs.getBool('debug_mode') ?? false; - final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; - final mtu = widget.prefs.getString('mtu') ?? '1140'; - final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; - final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; - final tunStack = 'ostp'; - - final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; - final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; - final configMap = { - "mode": "client", - "debug": debugMode, - "ostp": { - "server_addr": _serverAddr, - "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, - "handshake_timeout_ms": 10000, - "io_timeout_ms": 5000, - "mtu": int.tryParse(mtu) ?? 1140, - }, - "local_proxy": { - "bind_addr": localBind, - "connect_timeout_ms": 15000, - }, - "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, - }, - "multiplex": { - "enabled": muxEnabled, - "sessions": int.tryParse(muxSessions) ?? 2, - }, - "tun": { - "enable": true, - "stack": tunStack - }, - "exclusions": { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), - }, - "app_rules": { - "mode": appRoutingMode, - "packages": appRoutingPackages, - }, - "dns_server": dnsServer, - "tun_stack": tunStack - }; - - widget.prefs.setString('latest_config_json', jsonEncode(configMap)); - + final configMap = _buildConfigMap(); + final configStr = jsonEncode(configMap); + widget.prefs.setString('latest_config_json', configStr); try { - await platform.invokeMethod('saveConfig', { - "configJson": jsonEncode(configMap) - }); - await platform.invokeMethod('startTunnel', { - "configJson": jsonEncode(configMap) - }); - + await platform.invokeMethod('saveConfig', {"configJson": configStr}); + await platform.invokeMethod('startTunnel', {"configJson": configStr}); + bool started = false; for (int i = 0; i < 10; i++) { await Future.delayed(const Duration(milliseconds: 500)); @@ -240,7 +188,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { break; } } - + if (started) { _setConnected(); } else { @@ -289,30 +237,34 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } } + /// Cycles transport mode x MTU to find a working combination against the + /// active profile's server. WSS/Reality are gone (the core dropped + /// TLS-mimicry transports entirely — see §A), so this only has udp/uot x + /// MTU left to probe; junk/frag stay at whatever the active profile has set. Future _runAutoMode() async { final mtus = [1500, 1350, 1280, 1140]; - final modes = [ - {'t': 'udp'}, - {'t': 'uot'}, - ]; + final modes = ['udp', 'uot']; - if (_serverAddr.isEmpty || _accessKey.isEmpty) { + final active = _activeProfile; + if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key first')), + const SnackBar(content: Text('Please select a profile with a server and key first')), ); return; } - for (var mode in modes) { - for (var mtu in mtus) { + final originalMode = active.transportMode; + final originalMtu = widget.prefs.getString('mtu') ?? '1140'; + + for (final mode in modes) { + for (final mtu in mtus) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)), + SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)), ); - // Update prefs await widget.prefs.setString('mtu', mtu.toString()); - await widget.prefs.setString('transport_mode', mode['t'] as String); + active.transportMode = mode; _updateLatestConfigJson(); setState(() { @@ -337,7 +289,6 @@ class _HomeScreenState extends State with TickerProviderStateMixin { if (started) { _setConnected(); - // Wait to see if connection is stable and ping is successful await Future.delayed(const Duration(seconds: 3)); try { final metricsJson = await platform.invokeMethod('getMetrics'); @@ -345,30 +296,37 @@ class _HomeScreenState extends State with TickerProviderStateMixin { final Map parsed = jsonDecode(metricsJson); final rttMs = parsed['rtt_ms'] as int? ?? 0; if (rttMs > 0) { + // Working combo found — persist it onto the profile. + _persistActiveProfile(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')), + SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')), ); } - return; // Stop on first working config + return; } } - } catch (e) { - // Ignore metrics error + } catch (_) { + // Ignore metrics error, fall through to try next combo. } - // Connection seems unstable or no ping, stop and try next await platform.invokeMethod('stopTunnel'); _setDisconnected(); } else { _setDisconnected(); } - } catch (e) { + } catch (_) { _setDisconnected(); } } } + // No working combo found — revert the active profile/mtu to what they + // were before probing so we don't leave it on a broken guess. + active.transportMode = originalMode; + await widget.prefs.setString('mtu', originalMtu); + _updateLatestConfigJson(); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Auto search finished. No working config found.')), @@ -376,14 +334,25 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } } + void _persistActiveProfile() { + final active = _activeProfile; + if (active == null) return; + final profiles = decodeProfiles(widget.prefs.getString('profiles_json')); + final idx = profiles.indexWhere((p) => p.id == active.id); + if (idx >= 0) { + profiles[idx] = active; + widget.prefs.setString('profiles_json', encodeProfiles(profiles)); + } + } + void _setConnected() { if (!mounted) return; setState(() { _state = ConnectionStateEnum.connected; }); _pulseController.stop(); - _pulseController.value = 1.0; - + _pulseController.value = 1.0; + _uptimeSecs = 0; _uptimeTimer?.cancel(); _uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -398,7 +367,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { if (!mounted) return; try { final isRunning = await platform.invokeMethod('isRunning'); - + if (isRunning == true && _state == ConnectionStateEnum.disconnected) { _setConnected(); } else if (isRunning == false && _state == ConnectionStateEnum.connected) { @@ -413,7 +382,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { final bytesRecv = parsed['bytes_recv'] as int? ?? 0; final connState = parsed['connection_state'] as int? ?? 2; final rttMs = parsed['rtt_ms'] as int? ?? 0; - + if (connState == 0) { try { await platform.invokeMethod('stopTunnel'); @@ -428,7 +397,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } return; } - + if (mounted) { setState(() { _download = _formatBytes(bytesRecv); @@ -462,15 +431,15 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Future _checkConnectionLatency() async { if (_state != ConnectionStateEnum.connected) return; - + setState(() { _isCheckingPing = true; _pingText = 'Updating...'; _pingColor = Colors.white70; }); - + await Future.delayed(const Duration(milliseconds: 500)); - + if (mounted) { setState(() { _isCheckingPing = false; @@ -506,7 +475,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Scaffold( body: Stack( children: [ @@ -538,7 +507,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ), ), - + SafeArea( child: LayoutBuilder( builder: (context, constraints) { @@ -577,13 +546,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { width: 12, height: 12, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: _state == ConnectionStateEnum.connected - ? theme.colorScheme.secondary + color: _state == ConnectionStateEnum.connected + ? theme.colorScheme.secondary : theme.colorScheme.primary, boxShadow: [ BoxShadow( - color: _state == ConnectionStateEnum.connected - ? theme.colorScheme.secondary.withOpacity(0.5) + color: _state == ConnectionStateEnum.connected + ? theme.colorScheme.secondary.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.5), blurRadius: 10, ) @@ -677,7 +646,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ), ), - + AnimatedBuilder( animation: _pulseController, builder: (context, child) { @@ -722,9 +691,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ], ), ), - + const SizedBox(height: 40), - + Text( _state == ConnectionStateEnum.disconnected ? 'Disconnected' : _state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected', @@ -742,105 +711,103 @@ class _HomeScreenState extends State with TickerProviderStateMixin { color: Colors.white54, ), ), - + const SizedBox(height: 30), - - AnimatedOpacity( - opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: Column( + + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.08), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.15)), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.08), - borderRadius: BorderRadius.circular(30), - border: Border.all(color: Colors.white.withOpacity(0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), - const SizedBox(width: 10), - Text( - _serverAddr, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 15, - fontWeight: FontWeight.w600, - color: Colors.white70, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.03), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.06)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'CONNECTION TEST', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white38, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: 4), - Text( - _pingText, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: _pingColor, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - _isCheckingPing - ? const SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), - ) - : TextButton.icon( - onPressed: _checkConnectionLatency, - icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary), - label: Text( - 'Test Ping', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13, - color: theme.colorScheme.primary, - ), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - backgroundColor: theme.colorScheme.primary.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ], + const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), + const SizedBox(width: 10), + Text( + _activeProfile?.name ?? 'No profile selected', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white70, ), ), ], ), + ), + + AnimatedOpacity( + opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.03), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.06)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'CONNECTION TEST', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white38, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + _pingText, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: _pingColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _isCheckingPing + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), + ) + : TextButton.icon( + onPressed: _checkConnectionLatency, + icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary), + label: Text( + 'Test Ping', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: theme.colorScheme.primary, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + backgroundColor: theme.colorScheme.primary.withOpacity(0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ), ) ], ); @@ -911,4 +878,3 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ); } } - diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index f26ca1b..53d7eeb 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -1,11 +1,7 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; import 'app_routing_screen.dart'; import 'logs_screen.dart'; import 'qr_scanner_screen.dart'; @@ -13,6 +9,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../models/ostp_profile.dart'; class SettingsScreen extends StatefulWidget { final SharedPreferences prefs; @@ -23,104 +20,450 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - late TextEditingController _importCtrl; - late TextEditingController _serverCtrl; late TextEditingController _localBindCtrl; - late TextEditingController _keyCtrl; late TextEditingController _dnsCtrl; late TextEditingController _mtuCtrl; late TextEditingController _domainsCtrl; late TextEditingController _ipsCtrl; late TextEditingController _processesCtrl; - late TextEditingController _stealthSniCtrl; - - bool _obscureKey = true; - bool _debugMode = false; - String _transportMode = 'udp'; // 'udp' | 'uot' - String _tunStack = 'ostp'; // 'system' | 'ostp' - bool _muxEnabled = false; late TextEditingController _muxSessionsCtrl; + + bool _debugMode = false; + bool _muxEnabled = false; bool _isCheckingUpdates = false; + List _profiles = []; + @override void initState() { super.initState(); - _importCtrl = TextEditingController(); - _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443'); + _loadSettings(); + } + + void _loadSettings() { _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); - _keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? ''); _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1'); _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); - _stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? ''); - _transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; _debugMode = widget.prefs.getBool('debug_mode') ?? false; _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); + _profiles = decodeProfiles(widget.prefs.getString('profiles_json')); } @override void dispose() { _saveSettings(); - _importCtrl.dispose(); - _serverCtrl.dispose(); _localBindCtrl.dispose(); - _keyCtrl.dispose(); _dnsCtrl.dispose(); _mtuCtrl.dispose(); _domainsCtrl.dispose(); _ipsCtrl.dispose(); _processesCtrl.dispose(); - _stealthSniCtrl.dispose(); _muxSessionsCtrl.dispose(); super.dispose(); } void _saveSettings() { - widget.prefs.setString('server_addr', _serverCtrl.text.trim()); widget.prefs.setString('local_bind', _localBindCtrl.text.trim()); - widget.prefs.setString('access_key', _keyCtrl.text.trim()); widget.prefs.setString('dns_server', _dnsCtrl.text.trim()); widget.prefs.setString('mtu', _mtuCtrl.text.trim()); widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); widget.prefs.setString('ex_processes', _processesCtrl.text.trim()); widget.prefs.setBool('debug_mode', _debugMode); - widget.prefs.setString('transport_mode', _transportMode); - widget.prefs.setString('tun_stack', _tunStack); - widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim()); widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); + widget.prefs.setString('profiles_json', encodeProfiles(_profiles)); } - Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - TextField( - controller: controller, - obscureText: isPassword && _obscureKey, - maxLines: maxLines, - style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'), - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Colors.white30), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - suffixIcon: isPassword ? IconButton( - icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54), - onPressed: () => setState(() => _obscureKey = !_obscureKey), - ) : null, - ), + + void _saveProfiles() { + widget.prefs.setString('profiles_json', encodeProfiles(_profiles)); + } + + // ── Profile CRUD ───────────────────────────────────────────────────────── + + void _selectActive(OstpProfile p) { + setState(() { + for (final other in _profiles) { + other.active = other.id == p.id; + } + _saveProfiles(); + }); + } + + void _importFromLink(String link) { + if (link.isEmpty) return; + try { + if (!link.startsWith('ostp://')) { + throw Exception('Link must start with ostp://'); + } + final uri = Uri.parse(link); + final key = Uri.decodeComponent(uri.userInfo); + final host = uri.authority.replaceFirst('${uri.userInfo}@', ''); + if (key.isEmpty || host.isEmpty) { + throw Exception('Incomplete link parameters'); + } + final type = uri.queryParameters['type']; + final transportMode = (type == 'tcp' || type == 'http') ? 'uot' : 'udp'; + final name = uri.queryParameters['name'] ?? host; + final stealthSni = uri.queryParameters['sni'] ?? ''; + final wasEmpty = _profiles.isEmpty; + + setState(() { + _profiles.add(OstpProfile( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: name, + serverAddr: host, + accessKey: key, + transportMode: transportMode, + stealthSni: stealthSni, + active: wasEmpty, + )); + _saveProfiles(); + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + + void _showAddProfileMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_scanner, color: Colors.white), + title: const Text('Import from QR code'), + onTap: () async { + Navigator.pop(context); + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QRScannerScreen()), + ); + if (result != null && result is String && result.startsWith('ostp://')) { + _importFromLink(result); + } + }, + ), + ListTile( + leading: const Icon(Icons.link, color: Colors.white), + title: const Text('Import from link'), + onTap: () { + Navigator.pop(context); + _showImportLinkDialog(); + }, + ), + ListTile( + leading: const Icon(Icons.edit, color: Colors.white), + title: const Text('Insert manually'), + onTap: () { + Navigator.pop(context); + _showEditProfileDialog(null); + }, + ), + ], ), - const SizedBox(height: 24), - ], + ), + ); + } + + void _showImportLinkDialog() { + final linkCtrl = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Import Link'), + backgroundColor: Theme.of(context).colorScheme.surface, + content: TextField( + controller: linkCtrl, + decoration: const InputDecoration(hintText: 'ostp://...'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.pop(context); + _importFromLink(linkCtrl.text.trim()); + }, + child: const Text('Import'), + ), + ], + ), + ); + } + + static const List _stealthDomains = [ + 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me', + 'top-fwz1.mail.ru', 'sso.passport.yandex.ru', + 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com', + 'login.vk.com', 'api.sberbank.ru', 'ok.ru', + 'rostelecom.ru', 'rt.ru', 'tinkoff.ru', + 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com', + ]; + + void _showEditProfileDialog(OstpProfile? profile) { + final isNew = profile == null; + final nameCtrl = TextEditingController(text: profile?.name ?? ''); + final serverCtrl = TextEditingController(text: profile?.serverAddr ?? ''); + final keyCtrl = TextEditingController(text: profile?.accessKey ?? ''); + final fragChunkCtrl = TextEditingController(text: (profile?.fragChunk ?? 2).toString()); + final fragSleepCtrl = TextEditingController(text: (profile?.fragSleep ?? 2).toString()); + final junkPcMinCtrl = TextEditingController(text: (profile?.junkPcMin ?? 2).toString()); + final junkPcMaxCtrl = TextEditingController(text: (profile?.junkPcMax ?? 5).toString()); + final junkPsMinCtrl = TextEditingController(text: (profile?.junkPsMin ?? 100).toString()); + final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString()); + String transportMode = profile?.transportMode ?? 'udp'; + bool tcpFragmentation = profile?.tcpFragmentation ?? false; + String stealthSni = (profile?.stealthSni.isNotEmpty ?? false) ? profile!.stealthSni : 'vk.com'; + bool obscureKey = true; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(isNew ? 'New Profile' : 'Edit Profile'), + backgroundColor: Theme.of(context).colorScheme.surface, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name')), + const SizedBox(height: 12), + TextField(controller: serverCtrl, decoration: const InputDecoration(labelText: 'Server Address (host:port)')), + const SizedBox(height: 12), + TextField( + controller: keyCtrl, + obscureText: obscureKey, + decoration: InputDecoration( + labelText: 'Access Key', + suffixIcon: IconButton( + icon: Icon(obscureKey ? Icons.visibility : Icons.visibility_off, size: 18), + onPressed: () => setDialogState(() => obscureKey = !obscureKey), + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: transportMode, + decoration: const InputDecoration(labelText: 'Transport'), + items: const [ + DropdownMenuItem(value: 'udp', child: Text('UDP')), + DropdownMenuItem(value: 'uot', child: Text('TCP (UoT) — xHTTP stealth')), + ], + onChanged: (v) { + if (v != null) setDialogState(() => transportMode = v); + }, + ), + if (transportMode == 'uot') ...[ + const SizedBox(height: 12), + Builder(builder: (context) { + final domains = [..._stealthDomains]; + if (!domains.contains(stealthSni)) domains.add(stealthSni); + return DropdownButtonFormField( + value: stealthSni, + decoration: const InputDecoration(labelText: 'Stealth SNI domain'), + items: domains.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(), + onChanged: (v) { + if (v != null) setDialogState(() => stealthSni = v); + }, + ); + }), + ], + const Divider(height: 32), + // ── Junk packets + TCP fragmentation — same per-profile + // fields/defaults as the desktop GUI's profile editor. ── + const Text('DPI obfuscation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: TextField(controller: junkPcMinCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk packets (min)'))), + const SizedBox(width: 12), + Expanded(child: TextField(controller: junkPcMaxCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk packets (max)'))), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: TextField(controller: junkPsMinCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk size (min, bytes)'))), + const SizedBox(width: 12), + Expanded(child: TextField(controller: junkPsMaxCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk size (max, bytes)'))), + ], + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('TCP Fragmentation', style: TextStyle(fontSize: 14)), + subtitle: const Text('Split the handshake into small chunks', style: TextStyle(fontSize: 12, color: Colors.white54)), + value: tcpFragmentation, + onChanged: (v) => setDialogState(() => tcpFragmentation = v), + ), + if (tcpFragmentation) ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded(child: TextField(controller: fragChunkCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Chunk size (bytes)'))), + const SizedBox(width: 12), + Expanded(child: TextField(controller: fragSleepCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Delay (ms)'))), + ], + ), + ], + ], + ), + ), + actions: [ + if (!isNew) + TextButton( + onPressed: () { + setState(() { + final wasActive = profile.active; + _profiles.removeWhere((p) => p.id == profile.id); + if (wasActive && _profiles.isNotEmpty) { + _profiles.first.active = true; + } + _saveProfiles(); + }); + Navigator.pop(context); + }, + child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () { + final server = serverCtrl.text.trim(); + final key = keyCtrl.text.trim(); + if (server.isEmpty || key.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Server and Access Key are required')), + ); + return; + } + setState(() { + if (isNew) { + final wasEmpty = _profiles.isEmpty; + _profiles.add(OstpProfile( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server, + serverAddr: server, + accessKey: key, + transportMode: transportMode, + stealthSni: stealthSni, + active: wasEmpty, + tcpFragmentation: tcpFragmentation, + fragChunk: int.tryParse(fragChunkCtrl.text) ?? 2, + fragSleep: int.tryParse(fragSleepCtrl.text) ?? 2, + junkPcMin: int.tryParse(junkPcMinCtrl.text) ?? 2, + junkPcMax: int.tryParse(junkPcMaxCtrl.text) ?? 5, + junkPsMin: int.tryParse(junkPsMinCtrl.text) ?? 100, + junkPsMax: int.tryParse(junkPsMaxCtrl.text) ?? 1000, + )); + } else { + profile.name = nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server; + profile.serverAddr = server; + profile.accessKey = key; + profile.transportMode = transportMode; + profile.stealthSni = stealthSni; + profile.tcpFragmentation = tcpFragmentation; + profile.fragChunk = int.tryParse(fragChunkCtrl.text) ?? 2; + profile.fragSleep = int.tryParse(fragSleepCtrl.text) ?? 2; + profile.junkPcMin = int.tryParse(junkPcMinCtrl.text) ?? 2; + profile.junkPcMax = int.tryParse(junkPcMaxCtrl.text) ?? 5; + profile.junkPsMin = int.tryParse(junkPsMinCtrl.text) ?? 100; + profile.junkPsMax = int.tryParse(junkPsMaxCtrl.text) ?? 1000; + } + _saveProfiles(); + }); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + }, + ); + } + + void _showShareModal(OstpProfile p) { + final key = Uri.encodeComponent(p.accessKey); + if (p.serverAddr.isEmpty || p.accessKey.isEmpty) return; + final queryParams = []; + if (p.stealthSni.isNotEmpty) queryParams.add('sni=${Uri.encodeComponent(p.stealthSni)}'); + if (p.transportMode != 'udp') queryParams.add('type=${p.transportMode}'); + final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}'; + final url = 'ostp://$key@${p.serverAddr}$queryString'; + + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Share "${p.name}"', textAlign: TextAlign.center), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + child: QrImageView(data: url, version: QrVersions.auto, size: 200.0), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); + Navigator.pop(context); + }, + icon: const Icon(Icons.copy_rounded, color: Colors.white), + label: const Text('Copy Link', style: TextStyle(color: Colors.white)), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close'))], + ), + ); + } + + // ── Widgets ────────────────────────────────────────────────────────────── + + Widget _buildTextField(String label, TextEditingController controller, {String? hint, int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 10), + TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle(fontSize: 16), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Colors.white30), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ), + ], + ), ); } @@ -143,18 +486,56 @@ class _SettingsScreenState extends State { Switch( value: value, onChanged: (v) { - onChanged(v); + setState(() => onChanged(v)); _saveSettings(); }, activeColor: Theme.of(context).colorScheme.secondary, - activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3), - inactiveTrackColor: Colors.white10, ) ], ), ); } + List _buildProfileCards() { + String? activeId; + for (final x in _profiles) { + if (x.active) { activeId = x.id; break; } + } + return _profiles.map((p) => Card( + color: p.active + ? Theme.of(context).colorScheme.primary.withOpacity(0.12) + : Theme.of(context).colorScheme.surface, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: p.active ? BorderSide(color: Theme.of(context).colorScheme.primary.withOpacity(0.4)) : BorderSide.none, + ), + child: ListTile( + leading: Radio( + value: p.id, + groupValue: activeId, + onChanged: (_) => _selectActive(p), + ), + title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.qr_code_rounded, size: 20, color: Colors.white54), + onPressed: () => _showShareModal(p), + ), + IconButton( + icon: const Icon(Icons.edit, size: 20, color: Colors.white54), + onPressed: () => _showEditProfileDialog(p), + ), + ], + ), + onTap: () => _selectActive(p), + ), + )).toList(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -168,87 +549,31 @@ class _SettingsScreenState extends State { ), actions: [ IconButton( - icon: const Icon(Icons.share_rounded), - tooltip: 'Share Config', - onPressed: _showShareModal, + icon: const Icon(Icons.add_rounded), + tooltip: 'Add Profile', + onPressed: _showAddProfileMenu, ), - IconButton( - icon: const Icon(Icons.qr_code_scanner_rounded), - onPressed: () async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const QRScannerScreen()), - ); - if (result != null && result is String && result.startsWith('ostp://')) { - setState(() { - _importCtrl.text = result; - }); - } - }, - ) ], ), body: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - // Quick Import Row - Row( - children: [ - Expanded( - child: TextField( - controller: _importCtrl, - decoration: InputDecoration( - hintText: 'Paste ostp:// share link...', - hintStyle: const TextStyle(color: Colors.white30, fontSize: 14), - filled: true, - fillColor: Colors.white.withOpacity(0.05), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), + const Text('PROFILES', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 16), + if (_profiles.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text('Create a new profile', style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 18)), ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: () { - final raw = _importCtrl.text.trim(); - if (raw.isEmpty) return; - try { - if (!raw.startsWith('ostp://')) { - throw Exception('Link must start with ostp://'); - } - final uri = Uri.parse(raw); - final key = Uri.decodeComponent(uri.userInfo); - final host = uri.authority.replaceFirst(uri.userInfo + '@', ''); - if (key.isEmpty || host.isEmpty) { - throw Exception('Incomplete link parameters'); - } - setState(() { - _serverCtrl.text = host; - _keyCtrl.text = key; - _stealthSniCtrl.text = uri.queryParameters['sni'] ?? ''; - final type = uri.queryParameters['type'] ?? 'udp'; - _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; - _importCtrl.clear(); + ) + else + ..._buildProfileCards(), + + const SizedBox(height: 32), + const Text('CLIENT SETTINGS', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 16), - _saveSettings(); - }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'))); - } - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - backgroundColor: Theme.of(context).colorScheme.primary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white)), - ) - ], - ), - - const SizedBox(height: 30), - Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( @@ -259,166 +584,18 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTextField('Server Address', _serverCtrl, hint: 'host:port'), + _buildToggle('MUX (Multiplexing)', 'Multiple sessions over single connection', _muxEnabled, (v) => _muxEnabled = v), + if (_muxEnabled) + _buildTextField('MUX Sessions', _muxSessionsCtrl, hint: 'e.g. 2, 4, 8'), + + _buildToggle('Debug Mode', 'Verbose logging', _debugMode, (v) => _debugMode = v), + _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'), - _buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true), _buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'), _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'), - // ── Transport Mode ─────────────────────────────────────── - const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - RadioListTile( - value: 'udp', - groupValue: _transportMode, - title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.secondary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - Divider(color: Colors.white.withOpacity(0.05), height: 1), - RadioListTile( - value: 'uot', - groupValue: _transportMode, - title: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - children: [ - const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)), - ), - ], - ), - subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Stealth parameters - AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.06), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)), - const SizedBox(width: 8), - const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)), - ], - ), - const SizedBox(height: 4), - const Text( - 'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.', - style: TextStyle(fontSize: 12, color: Colors.white38), - ), - const SizedBox(height: 16), - Builder(builder: (context) { - final List domains = [ - 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me', - 'top-fwz1.mail.ru', 'sso.passport.yandex.ru', - 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com', - 'login.vk.com', 'api.sberbank.ru', 'ok.ru', - 'rostelecom.ru', 'rt.ru', 'tinkoff.ru', - 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com' - ]; - String currentVal = _stealthSniCtrl.text.trim(); - if (currentVal.isEmpty) currentVal = 'vk.com'; - if (!domains.contains(currentVal)) { - domains.add(currentVal); - } - return DropdownButtonFormField( - value: currentVal, - dropdownColor: const Color(0xFF1E1E2C), - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - labelText: 'Стелс Домен (Автоподставление)', - labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - items: domains.map((String domain) { - return DropdownMenuItem( - value: domain, - child: Text(domain), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _stealthSniCtrl.text = newValue; - _saveSettings(); - }); - } - }, - ); - }), - - ], - ), - ), - secondChild: const SizedBox.shrink(), - ), - - - const SizedBox(height: 16), - _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)), - AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'), - ), - secondChild: const SizedBox.shrink(), - ), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))), - Padding( - padding: const EdgeInsets.only(bottom: 24.0, left: 10), - child: IconButton( - icon: const Icon(Icons.receipt_long_rounded), - color: Theme.of(context).colorScheme.primary, - tooltip: 'View Logs', - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); - }, - ), - ), - ], - ), - - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), + padding: EdgeInsets.only(bottom: 16), child: Row( children: [ Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), @@ -427,57 +604,38 @@ class _SettingsScreenState extends State { ], ), ), - - _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true), - _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true), - - // Premium app routing trigger button - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), - ), - child: Row( - children: [ - Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Per-App Connection Rules', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - SizedBox(height: 4), - Text( - 'Choose which apps bypass or use VPN', - style: TextStyle(fontSize: 13, color: Colors.white54), - ), - ], - ), - ), - const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), - ], - ), + _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3), + _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3), + _buildTextField('Bypass Processes', _processesCtrl, hint: 'com.example.app', maxLines: 3), + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.route), + label: const Text('Configure Split Tunneling'), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs))); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.article), + label: const Text('View Logs'), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); + }, ), ), - const SizedBox(height: 10), ], ), ), - + const SizedBox(height: 16), - + InkWell( onTap: _isCheckingUpdates ? null : _checkForUpdates, child: Container( @@ -489,117 +647,36 @@ class _SettingsScreenState extends State { ), child: Row( children: [ - Icon(Icons.system_update_rounded, color: Colors.white70, size: 24), + const Icon(Icons.system_update_rounded, color: Colors.white70, size: 24), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Check for Updates', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - SizedBox(height: 4), + const Text('Check for Updates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)), + const SizedBox(height: 4), Text( _isCheckingUpdates ? 'Checking...' : 'Check latest release on GitHub', - style: TextStyle(fontSize: 13, color: Colors.white54), + style: const TextStyle(fontSize: 13, color: Colors.white54), ), ], ), ), if (_isCheckingUpdates) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54), - ) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)) else const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), ], ), ), ), - + const SizedBox(height: 40), ], ), ); } - String _generateShareUrl() { - final host = _serverCtrl.text.trim(); - final key = Uri.encodeComponent(_keyCtrl.text.trim()); - if (host.isEmpty || key.isEmpty) return ''; - - final queryParams = []; - if (_stealthSniCtrl.text.trim().isNotEmpty) { - queryParams.add('sni=${Uri.encodeComponent(_stealthSniCtrl.text.trim())}'); - } - if (_transportMode != 'udp') { - queryParams.add('type=$_transportMode'); - } - - final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}'; - return 'ostp://$key@$host$queryString'; - } - - void _showShareModal() { - final url = _generateShareUrl(); - if (url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Server Address and Access Key are required to share.'))); - return; - } - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('Share Config', textAlign: TextAlign.center), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: QrImageView( - data: url, - version: QrVersions.auto, - size: 200.0, - ), - ), - const SizedBox(height: 20), - ElevatedButton.icon( - onPressed: () { - Clipboard.setData(ClipboardData(text: url)); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); - Navigator.pop(context); - }, - icon: const Icon(Icons.copy_rounded, color: Colors.white), - label: const Text('Copy Link', style: TextStyle(color: Colors.white)), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ) - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ) - ], - ); - } - ); - } - Future _checkForUpdates() async { if (_isCheckingUpdates) return; setState(() { _isCheckingUpdates = true; }); @@ -611,7 +688,6 @@ class _SettingsScreenState extends State { if (response.statusCode == 200) { final data = json.decode(response.body); final latestVersion = (data['tag_name'] as String).replaceAll('v', ''); - final hasUpdate = latestVersion != currentVersion; if (!mounted) return; @@ -621,14 +697,11 @@ class _SettingsScreenState extends State { return AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: Text(hasUpdate ? 'Update Available!' : 'Up to Date'), - content: Text(hasUpdate - ? 'A new version ($latestVersion) is available on GitHub. You are currently running version $currentVersion.' - : 'You are running the latest version ($currentVersion).'), + content: Text(hasUpdate + ? 'A new version ($latestVersion) is available on GitHub. You are currently running version $currentVersion.' + : 'You are running the latest version ($currentVersion).'), actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), if (hasUpdate) TextButton( onPressed: () { @@ -640,7 +713,7 @@ class _SettingsScreenState extends State { ) ], ); - } + }, ); } else { throw Exception('HTTP ${response.statusCode}'); @@ -653,4 +726,3 @@ class _SettingsScreenState extends State { } } } -