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'; void main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); runApp(OstpApp(prefs: prefs)); } class OstpApp extends StatelessWidget { final SharedPreferences prefs; const OstpApp({super.key, required this.prefs}); @override Widget build(BuildContext context) { return MaterialApp( title: 'OSTP Client', debugShowCheckedModeBanner: false, theme: ThemeData( brightness: Brightness.dark, scaffoldBackgroundColor: const Color(0xFF08080F), colorScheme: const ColorScheme.dark( primary: Color(0xFF6C72FF), secondary: Color(0xFF22D3A5), surface: Color(0xFF151522), ), fontFamily: 'Inter', useMaterial3: true, ), home: HomeScreen(prefs: prefs), ); } } class HomeScreen extends StatefulWidget { final SharedPreferences prefs; const HomeScreen({super.key, required this.prefs}); @override State createState() => _HomeScreenState(); } enum ConnectionStateEnum { disconnected, connecting, connected } 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'; String _download = '0 B'; String _upload = '0 B'; late AnimationController _pulseController; late AnimationController _spinController; bool _isCheckingPing = false; String _pingText = 'Target Ping: -- ms'; Color _pingColor = Colors.white54; @override void initState() { super.initState(); _loadSettings(); _pulseController = AnimationController( vsync: this, duration: const Duration(seconds: 2), ); _spinController = AnimationController( vsync: this, duration: const Duration(seconds: 4), ); _checkInitialState(); } Future _checkInitialState() async { try { final isRunning = await platform.invokeMethod('isRunning'); if (isRunning == true && mounted) { _setConnected(); } } catch (e) { 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') ?? ''; }); _updateLatestConfigJson(); } void _updateLatestConfigJson() { 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 stealthPort = widget.prefs.getString('stealth_port') ?? '443'; final wss = widget.prefs.getBool('wss') ?? false; 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'; 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, "stealth_port": int.tryParse(stealthPort) ?? 443, "wss": wss, }, "multiplex": { "enabled": muxEnabled, "sessions": int.tryParse(muxSessions) ?? 2, }, "reality": { "enabled": widget.prefs.getBool('reality_enabled') ?? false, "dest": "", "private_key": "", "pbk": widget.prefs.getString('pbk') ?? "", "sid": widget.prefs.getString('sid') ?? "", "sni_list": [] }, "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": effectiveDnsServer, "tun_stack": tunStack }; widget.prefs.setString('latest_config_json', jsonEncode(configMap)); platform.invokeMethod('saveConfig', { "configJson": jsonEncode(configMap) }); } @override void dispose() { _pollTimer?.cancel(); _uptimeTimer?.cancel(); _pulseController.dispose(); _spinController.dispose(); super.dispose(); } Future _toggleConnection() async { if (_state == ConnectionStateEnum.disconnected) { if (_serverAddr.isEmpty || _accessKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please configure Server and Key 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 stealthPort = widget.prefs.getString('stealth_port') ?? '443'; final wss = widget.prefs.getBool('wss') ?? false; 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, "stealth_port": int.tryParse(stealthPort) ?? 443, "wss": wss, }, "multiplex": { "enabled": muxEnabled, "sessions": int.tryParse(muxSessions) ?? 2, }, "reality": { "enabled": widget.prefs.getBool('reality_enabled') ?? false, "dest": "", "private_key": "", "pbk": widget.prefs.getString('pbk') ?? "", "sid": widget.prefs.getString('sid') ?? "", "sni_list": [] }, "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)); try { await platform.invokeMethod('saveConfig', { "configJson": jsonEncode(configMap) }); await platform.invokeMethod('startTunnel', { "configJson": jsonEncode(configMap) }); bool started = false; for (int i = 0; i < 10; i++) { await Future.delayed(const Duration(milliseconds: 500)); final isRunning = await platform.invokeMethod('isRunning'); if (isRunning == true) { started = true; break; } } if (started) { _setConnected(); } else { _setDisconnected(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to connect. Check logs for details.')), ); } } } catch (e, stackTrace) { debugPrint("Failed to start tunnel: $e\n$stackTrace"); _setDisconnected(); if (mounted) { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Error', style: TextStyle(color: Colors.redAccent)), content: SingleChildScrollView( child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ), actions: [ TextButton( onPressed: () { Clipboard.setData(ClipboardData(text: e.toString())); ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); }, child: const Text('Copy'), ), TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Close'), ), ], ), ); } } } else { try { await platform.invokeMethod('stopTunnel'); } catch (e) { debugPrint("Stop error: $e"); } _setDisconnected(); } } Future _runAutoMode() async { final mtus = [1500, 1350, 1280, 1140]; final modes = [ {'t': 'udp', 'w': false, 'r': false}, {'t': 'uot', 'w': false, 'r': false}, {'t': 'uot', 'w': true, 'r': false}, {'t': 'uot', 'w': false, 'r': true}, ]; if (_serverAddr.isEmpty || _accessKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please configure Server and Key first')), ); return; } for (var mode in modes) { for (var mtu in mtus) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | 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); await widget.prefs.setBool('wss', mode['w'] as bool); await widget.prefs.setBool('reality_enabled', mode['r'] as bool); _updateLatestConfigJson(); setState(() { _state = ConnectionStateEnum.connecting; }); _pulseController.repeat(reverse: true); _spinController.repeat(); try { final configJson = widget.prefs.getString('latest_config_json') ?? '{}'; await platform.invokeMethod('startTunnel', {"configJson": configJson}); bool started = false; for (int i = 0; i < 10; i++) { await Future.delayed(const Duration(milliseconds: 500)); final isRunning = await platform.invokeMethod('isRunning'); if (isRunning == true) { started = true; break; } } 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'); if (metricsJson != null && metricsJson.isNotEmpty) { final Map parsed = jsonDecode(metricsJson); final rttMs = parsed['rtt_ms'] as int? ?? 0; if (rttMs > 0) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')), ); } return; // Stop on first working config } } } catch (e) { // Ignore metrics error } // Connection seems unstable or no ping, stop and try next await platform.invokeMethod('stopTunnel'); _setDisconnected(); } else { _setDisconnected(); } } catch (e) { _setDisconnected(); } } } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Auto search finished. No working config found.')), ); } } void _setConnected() { if (!mounted) return; setState(() { _state = ConnectionStateEnum.connected; }); _pulseController.stop(); _pulseController.value = 1.0; _uptimeSecs = 0; _uptimeTimer?.cancel(); _uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; setState(() => _uptimeSecs++); }); _startPollingMetrics(); } void _startPollingMetrics() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async { if (!mounted) return; try { final metricsJson = await platform.invokeMethod('getMetrics'); if (metricsJson != null && metricsJson.isNotEmpty) { final Map parsed = jsonDecode(metricsJson); final bytesSent = parsed['bytes_sent'] as int? ?? 0; 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 && _state != ConnectionStateEnum.disconnected) { try { await platform.invokeMethod('stopTunnel'); } catch (e) { debugPrint("Failed to stop background tunnel: $e"); } _setDisconnected(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Connection failed. Check logs for details.')), ); } return; } if (mounted) { setState(() { _download = _formatBytes(bytesRecv); _upload = _formatBytes(bytesSent); if (rttMs > 0 && !_isCheckingPing) { _pingText = 'Server Ping: $rttMs ms'; if (rttMs < 100) { _pingColor = const Color(0xFF22D3A5); } else if (rttMs < 250) { _pingColor = Colors.amberAccent; } else { _pingColor = Colors.redAccent; } } }); } } } catch (e) { debugPrint("Failed to get metrics: $e"); } }); } String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } 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; }); } } void _setDisconnected() { if (!mounted) return; setState(() { _state = ConnectionStateEnum.disconnected; _download = '0 B'; _upload = '0 B'; _pingText = 'Target Ping: -- ms'; _pingColor = Colors.white54; _isCheckingPing = false; }); _pulseController.stop(); _pulseController.value = 0.0; _spinController.stop(); _uptimeTimer?.cancel(); _pollTimer?.cancel(); } String _formatTime(int s) { final h = s ~/ 3600; final m = (s % 3600) ~/ 60; final sec = s % 60; final pad = (int n) => n.toString().padLeft(2, '0'); return h > 0 ? '$h:${pad(m)}:${pad(sec)}' : '${pad(m)}:${pad(sec)}'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: Stack( children: [ Positioned( top: -150, right: -100, child: Container( width: 400, height: 400, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.colorScheme.primary.withOpacity(0.15), ), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), child: Container(), ), ), ), Positioned( bottom: -100, left: -100, child: Container( width: 350, height: 350, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.colorScheme.secondary.withOpacity(0.1), ), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), child: Container(), ), ), ), SafeArea( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: IntrinsicHeight( child: Column( children: [ _buildTopBar(theme), Expanded(child: _buildStage(theme)), _buildMetricsBar(theme), ], ), ), ), ); }, ), ), ], ), ); } Widget _buildTopBar(ThemeData theme) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ AnimatedContainer( duration: const Duration(milliseconds: 300), width: 12, height: 12, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: _state == ConnectionStateEnum.connected ? theme.colorScheme.secondary : theme.colorScheme.primary, boxShadow: [ BoxShadow( color: _state == ConnectionStateEnum.connected ? theme.colorScheme.secondary.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.5), blurRadius: 10, ) ] ), ), const SizedBox(width: 12), const Text( 'OSTP', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: 2.5, color: Colors.white, ), ), ], ), Row( children: [ IconButton( iconSize: 30, icon: const Icon(Icons.auto_mode_rounded, color: Colors.white), onPressed: () { if (_state != ConnectionStateEnum.disconnected) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Disconnect first to run Auto mode')), ); return; } _runAutoMode(); }, ), IconButton( iconSize: 30, icon: const Icon(Icons.settings_outlined, color: Colors.white), onPressed: () async { await Navigator.push( context, MaterialPageRoute(builder: (context) => SettingsScreen(prefs: widget.prefs)), ); _loadSettings(); }, ) ], ) ], ), ); } Widget _buildStage(ThemeData theme) { Color getAccentColor() { if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary; return theme.colorScheme.primary; } return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 260, height: 260, child: Stack( alignment: Alignment.center, children: [ if (_state != ConnectionStateEnum.disconnected) RotationTransition( turns: _spinController, child: Container( width: 240, height: 240, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: getAccentColor().withOpacity(0.25), width: 2.0, ), ), ), ), if (_state != ConnectionStateEnum.disconnected) RotationTransition( turns: ReverseAnimation(_spinController), child: Container( width: 200, height: 200, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: getAccentColor().withOpacity(0.15), width: 1.5, ), ), ), ), AnimatedBuilder( animation: _pulseController, builder: (context, child) { return Container( width: 140, height: 140, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.colorScheme.surface, border: Border.all( color: _state == ConnectionStateEnum.disconnected ? Colors.white.withOpacity(0.15) : getAccentColor(), width: 3, ), boxShadow: [ if (_state != ConnectionStateEnum.disconnected) BoxShadow( color: getAccentColor().withOpacity(0.4 * (_state == ConnectionStateEnum.connected ? 1.0 : _pulseController.value)), blurRadius: 40, spreadRadius: 8, ) ] ), child: child, ); }, child: Material( color: Colors.transparent, child: InkWell( customBorder: const CircleBorder(), onTap: _toggleConnection, child: Icon( Icons.power_settings_new_rounded, size: 60, color: _state == ConnectionStateEnum.disconnected ? Colors.white54 : getAccentColor(), ), ), ), ), ], ), ), const SizedBox(height: 40), Text( _state == ConnectionStateEnum.disconnected ? 'Disconnected' : _state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected', style: TextStyle( fontSize: 26, fontWeight: FontWeight.w700, color: _state == ConnectionStateEnum.disconnected ? Colors.white70 : getAccentColor(), ), ), const SizedBox(height: 8), Text( _state == ConnectionStateEnum.connected ? _formatTime(_uptimeSecs) : 'Tap to protect your traffic', style: const TextStyle( fontSize: 16, color: Colors.white54, ), ), const SizedBox(height: 30), AnimatedOpacity( opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0, duration: const Duration(milliseconds: 300), child: Column( 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: 32), 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: [ 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, style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: _pingColor, ), ), ], ), _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)), ), ), ], ), ), ], ), ) ], ); } Widget _buildMetricsBar(ThemeData theme) { return Container( padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), decoration: BoxDecoration( color: Colors.white.withOpacity(0.04), border: Border(top: BorderSide(color: Colors.white.withOpacity(0.08))), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary), Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)), _buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary), ], ), ); } Widget _buildMetricItem(IconData icon, String label, String value, Color color) { return Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(10), ), child: Icon(icon, size: 20, color: color), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label.toUpperCase(), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white54, letterSpacing: 0.8, ), ), const SizedBox(height: 4), Text( value, style: const TextStyle( fontFamily: 'monospace', fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white, ), ), ], ) ], ); } } class SettingsScreen extends StatefulWidget { final SharedPreferences prefs; const SettingsScreen({super.key, required this.prefs}); @override State createState() => _SettingsScreenState(); } 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; late TextEditingController _stealthPortCtrl; late TextEditingController _pbkCtrl; late TextEditingController _sidCtrl; bool _obscureKey = true; bool _debugMode = false; bool _wss = false; bool _realityEnabled = false; String _transportMode = 'udp'; // 'udp' | 'uot' String _tunStack = 'ostp'; // 'system' | 'ostp' bool _muxEnabled = false; late TextEditingController _muxSessionsCtrl; bool _owndns = false; @override void initState() { super.initState(); _importCtrl = TextEditingController(); _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443'); _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') ?? ''); _stealthPortCtrl = TextEditingController(text: widget.prefs.getString('stealth_port') ?? '443'); _pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? ''); _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); _wss = widget.prefs.getBool('wss') ?? false; _realityEnabled = widget.prefs.getBool('reality_enabled') ?? false; _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'); _owndns = widget.prefs.getBool('owndns') ?? false; } @override void dispose() { _saveSettings(); _importCtrl.dispose(); _serverCtrl.dispose(); _localBindCtrl.dispose(); _keyCtrl.dispose(); _dnsCtrl.dispose(); _mtuCtrl.dispose(); _domainsCtrl.dispose(); _ipsCtrl.dispose(); _processesCtrl.dispose(); _stealthSniCtrl.dispose(); _stealthPortCtrl.dispose(); _pbkCtrl.dispose(); _sidCtrl.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.setBool('wss', _wss); widget.prefs.setBool('reality_enabled', _realityEnabled); widget.prefs.setString('transport_mode', _transportMode); widget.prefs.setString('tun_stack', _tunStack); widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim()); widget.prefs.setString('stealth_port', _stealthPortCtrl.text.trim()); widget.prefs.setString('pbk', _pbkCtrl.text.trim()); widget.prefs.setString('sid', _sidCtrl.text.trim()); widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); widget.prefs.setBool('owndns', _owndns); } 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, ), ), const SizedBox(height: 24), ], ); } Widget _buildToggle(String title, String subtitle, bool value, ValueChanged onChanged) { return Padding( padding: const EdgeInsets.only(bottom: 24), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)), ], ), ), Switch( value: value, onChanged: (v) { onChanged(v); _saveSettings(); }, activeColor: Theme.of(context).colorScheme.secondary, activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3), inactiveTrackColor: Colors.white10, ) ], ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)), backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () => Navigator.pop(context), ), actions: [ 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 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'] ?? ''; _pbkCtrl.text = uri.queryParameters['pbk'] ?? ''; _sidCtrl.text = uri.queryParameters['sid'] ?? ''; _wss = uri.queryParameters['wss'] == 'true'; _realityEnabled = uri.queryParameters['reality'] == 'true'; final type = uri.queryParameters['type'] ?? 'udp'; _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; _owndns = uri.queryParameters['owndns'] == 'true'; _importCtrl.clear(); _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( color: Colors.white.withOpacity(0.02), borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.withOpacity(0.05)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTextField('Server Address', _serverCtrl, hint: 'host:port'), _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), _buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) { setState(() { _wss = val; }); }), 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; _stealthPortCtrl.text = '443'; _saveSettings(); }); } }, ); }), const SizedBox(height: 16), _buildToggle('XTLS-Reality', 'Подделка TLS-сессии (Stealth-домен должен быть TLS 1.3)', _realityEnabled, (val) { setState(() { _realityEnabled = val; }); }), const SizedBox(height: 16), _buildTextField('Reality PublicKey (pbk)', _pbkCtrl, hint: 'Публичный ключ сервера'), _buildTextField('Reality ShortId (sid)', _sidCtrl, hint: 'Опционально (необязательно)'), ], ), ), 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), child: Row( children: [ Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(width: 10), Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)), ], ), ), _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), ], ), ), ), const SizedBox(height: 10), ], ), ), const SizedBox(height: 40), ], ), ); } } class LogsScreen extends StatefulWidget { const LogsScreen({super.key}); @override State createState() => _LogsScreenState(); } class _LogsScreenState extends State { static const platform = MethodChannel('com.ospab.ostp/vpn'); Timer? _pollTimer; final List _logs = []; final ScrollController _scrollCtrl = ScrollController(); @override void initState() { super.initState(); _fetchLogs(); _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _fetchLogs()); } @override void dispose() { _pollTimer?.cancel(); _scrollCtrl.dispose(); super.dispose(); } Future _fetchLogs() async { try { final String logsJson = await platform.invokeMethod('getLogs'); if (logsJson.isNotEmpty && logsJson != "[]") { final List parsed = jsonDecode(logsJson); if (parsed.isNotEmpty) { setState(() { _logs.addAll(parsed.map((e) => e.toString())); }); Future.delayed(const Duration(milliseconds: 100), () { if (_scrollCtrl.hasClients) { _scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } }); } } } catch (e, stackTrace) { debugPrint("Failed to fetch logs: $e\n$stackTrace"); if (mounted) { Navigator.of(context).popUntil((route) => route.isFirst); showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Logs Error', style: TextStyle(color: Colors.redAccent)), content: SingleChildScrollView( child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ), actions: [ TextButton( onPressed: () { Clipboard.setData(ClipboardData(text: e.toString())); ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); }, child: const Text('Copy'), ), TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Close'), ), ], ), ); } } } Future _clearLogs() async { await platform.invokeMethod('clearLogs'); setState(() { _logs.clear(); }); } Future _copyLogs() async { final text = _logs.join('\n'); await Clipboard.setData(ClipboardData(text: text)); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Logs copied to clipboard'))); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('System Logs', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), backgroundColor: Theme.of(context).colorScheme.surface, elevation: 0, actions: [ IconButton(icon: const Icon(Icons.delete_outline), onPressed: _clearLogs, tooltip: 'Clear'), IconButton(icon: const Icon(Icons.copy_rounded), onPressed: _copyLogs, tooltip: 'Copy All'), ], ), body: Container( color: Colors.black, padding: const EdgeInsets.all(12), child: ListView.builder( controller: _scrollCtrl, itemCount: _logs.length, itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: Text( _logs[index], style: const TextStyle( fontFamily: 'monospace', fontSize: 12, color: Colors.greenAccent, ), ), ); }, ), ), ); } } class AppRoutingScreen extends StatefulWidget { final SharedPreferences prefs; const AppRoutingScreen({super.key, required this.prefs}); @override State createState() => _AppRoutingScreenState(); } class _AppRoutingScreenState extends State { static const platform = MethodChannel('com.ospab.ostp/vpn'); List> _allApps = []; List> _filteredApps = []; Set _selectedPackages = {}; String _routingMode = 'bypass'; bool _hideSystemApps = true; bool _isLoading = true; String _searchQuery = ''; final TextEditingController _searchCtrl = TextEditingController(); @override void initState() { super.initState(); _loadSavedConfig(); _fetchInstalledApps(); } void _loadSavedConfig() { setState(() { _routingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; _selectedPackages = (widget.prefs.getStringList('app_routing_packages') ?? []).toSet(); }); } Future _fetchInstalledApps() async { try { final List? rawApps = await platform.invokeMethod('getInstalledApps'); if (rawApps != null) { final List> apps = rawApps.map((e) { final Map m = e as Map; return { "name": m["name"] as String? ?? "Unknown", "package": m["package"] as String? ?? "", "isSystem": m["isSystem"] as bool? ?? false, "icon": m["icon"] as String? ?? "", }; }).toList(); apps.sort((a, b) => (a["name"] as String).toLowerCase().compareTo((b["name"] as String).toLowerCase())); setState(() { _allApps = apps; _isLoading = false; }); _filterApps(); } } catch (e) { debugPrint("Error fetching apps: $e"); setState(() => _isLoading = false); } } void _filterApps() { setState(() { _filteredApps = _allApps.where((app) { final name = (app["name"] as String).toLowerCase(); final package = (app["package"] as String).toLowerCase(); final query = _searchQuery.toLowerCase(); final matchesSearch = name.contains(query) || package.contains(query); final matchesSystemFilter = !_hideSystemApps || !(app["isSystem"] as bool); return matchesSearch && matchesSystemFilter; }).toList(); }); } void _saveConfig() { widget.prefs.setString('app_routing_mode', _routingMode); widget.prefs.setStringList('app_routing_packages', _selectedPackages.toList()); } void _resetConfig() { setState(() { _selectedPackages.clear(); _routingMode = 'bypass'; _hideSystemApps = true; _searchCtrl.clear(); _searchQuery = ''; }); _saveConfig(); _filterApps(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('App routing rules reset successfully')), ); } @override void dispose() { _searchCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('App Routing Rules', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), backgroundColor: theme.colorScheme.surface, elevation: 0, actions: [ IconButton( icon: const Icon(Icons.refresh_rounded), tooltip: 'Reset Rules', onPressed: _resetConfig, ), ], ), body: Column( children: [ Container( padding: const EdgeInsets.all(16), color: theme.colorScheme.surface.withOpacity(0.5), child: Column( children: [ Row( children: [ Expanded( child: GestureDetector( onTap: () { setState(() { _routingMode = 'bypass'; }); _saveConfig(); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1), ), ), child: const Center( child: Text( 'Bypass Mode', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), ), ), ), ), ), const SizedBox(width: 12), Expanded( child: GestureDetector( onTap: () { setState(() { _routingMode = 'proxy'; }); _saveConfig(); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1), ), ), child: const Center( child: Text( 'Proxy Mode', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), ), ), ), ), ), ], ), const SizedBox(height: 8), Text( _routingMode == 'bypass' ? 'Selected apps bypass the VPN (direct connection).' : 'Only selected apps are routed through the VPN.', style: const TextStyle(fontSize: 13, color: Colors.white54), textAlign: TextAlign.center, ), ], ), ), Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Expanded( child: TextField( controller: _searchCtrl, onChanged: (val) { setState(() { _searchQuery = val; }); _filterApps(); }, decoration: InputDecoration( hintText: 'Search apps...', prefixIcon: const Icon(Icons.search_rounded, color: Colors.white54), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear_rounded, color: Colors.white54), onPressed: () { _searchCtrl.clear(); setState(() { _searchQuery = ''; }); _filterApps(); }, ) : null, filled: true, fillColor: Colors.white.withOpacity(0.05), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), ), const SizedBox(width: 12), InkWell( onTap: () { setState(() { _hideSystemApps = !_hideSystemApps; }); _filterApps(); }, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.15) : Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(16), border: Border.all( color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.4) : Colors.white.withOpacity(0.1), ), ), child: Icon( _hideSystemApps ? Icons.visibility_off_rounded : Icons.visibility_rounded, color: _hideSystemApps ? theme.colorScheme.primary : Colors.white70, ), ), ), ], ), ), Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) : _filteredApps.isEmpty ? const Center(child: Text('No applications found', style: TextStyle(color: Colors.white54))) : ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _filteredApps.length, itemBuilder: (context, index) { final app = _filteredApps[index]; final pkg = app["package"] as String; final name = app["name"] as String; final isSystem = app["isSystem"] as bool; final isSelected = _selectedPackages.contains(pkg); final String? iconBase64 = app["icon"] as String?; final String initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; final int colorHash = pkg.hashCode.abs(); final double hue = (colorHash % 360).toDouble(); return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: isSelected ? (_routingMode == 'bypass' ? theme.colorScheme.primary.withOpacity(0.08) : theme.colorScheme.secondary.withOpacity(0.08)) : Colors.white.withOpacity(0.02), borderRadius: BorderRadius.circular(16), border: Border.all( color: isSelected ? (_routingMode == 'bypass' ? theme.colorScheme.primary.withOpacity(0.3) : theme.colorScheme.secondary.withOpacity(0.3)) : Colors.white.withOpacity(0.05), ), ), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), leading: iconBase64 != null && iconBase64.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.memory( base64Decode(iconBase64), width: 40, height: 40, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Container( width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Center( child: Text( initial, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), ), ), ), ), ) : Container( width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Center( child: Text( initial, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), ), ), ), title: Row( children: [ Expanded( child: Text( name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isSystem) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(4), ), child: const Text( 'SYS', style: TextStyle(fontSize: 9, color: Colors.white60, fontWeight: FontWeight.bold), ), ) ] ], ), subtitle: Text( pkg, style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white38), maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Switch( value: isSelected, activeColor: _routingMode == 'bypass' ? theme.colorScheme.primary : theme.colorScheme.secondary, onChanged: (val) { setState(() { if (val) { _selectedPackages.add(pkg); } else { _selectedPackages.remove(pkg); } }); _saveConfig(); }, ), ), ); }, ), ), ], ), ); } } class QRScannerScreen extends StatefulWidget { const QRScannerScreen({super.key}); @override State createState() => _QRScannerScreenState(); } class _QRScannerScreenState extends State { final MobileScannerController controller = MobileScannerController( detectionSpeed: DetectionSpeed.normal, facing: CameraFacing.back, ); @override void dispose() { controller.dispose(); super.dispose(); } DateTime? lastErrorTime; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Scan QR Code'), backgroundColor: Colors.transparent, elevation: 0, ), body: Stack( alignment: Alignment.center, children: [ MobileScanner( controller: controller, onDetect: (capture) { final List barcodes = capture.barcodes; for (final barcode in barcodes) { if (barcode.rawValue != null) { if (barcode.rawValue!.startsWith('ostp://')) { controller.stop(); Navigator.pop(context, barcode.rawValue); return; } else { final now = DateTime.now(); if (lastErrorTime == null || now.difference(lastErrorTime!) > const Duration(seconds: 3)) { lastErrorTime = now; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invalid QR Code. Must be an OSTP connection link.'), backgroundColor: Colors.redAccent, duration: Duration(seconds: 2), ), ); } } } } }, ), Container( decoration: ShapeDecoration( shape: QrScannerOverlayShape( borderColor: Theme.of(context).colorScheme.primary, borderRadius: 10, borderLength: 30, borderWidth: 10, cutOutSize: 300, ), ), ), ], ), ); } } class QrScannerOverlayShape extends ShapeBorder { final Color borderColor; final double borderWidth; final double borderRadius; final double borderLength; final double cutOutSize; const QrScannerOverlayShape({ this.borderColor = Colors.red, this.borderWidth = 3.0, this.borderRadius = 0.0, this.borderLength = 20.0, this.cutOutSize = 250.0, }); @override EdgeInsetsGeometry get dimensions => const EdgeInsets.all(10); @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { return Path() ..fillType = PathFillType.evenOdd ..addPath(getOuterPath(rect), Offset.zero); } @override Path getOuterPath(Rect rect, {TextDirection? textDirection}) { Path path = Path()..addRect(rect); rect = Rect.fromCenter( center: rect.center, width: cutOutSize, height: cutOutSize, ); path.addRect(rect); return path; } @override void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { final borderPaint = Paint() ..color = borderColor ..style = PaintingStyle.stroke ..strokeWidth = borderWidth; final backgroundPaint = Paint() ..color = Colors.black54 ..style = PaintingStyle.fill; final cutOutRect = Rect.fromCenter( center: rect.center, width: cutOutSize, height: cutOutSize, ); final backgroundPath = Path() ..addRect(rect) ..addRect(cutOutRect) ..fillType = PathFillType.evenOdd; canvas.drawPath(backgroundPath, backgroundPaint); final path = Path(); // Top left path.moveTo(cutOutRect.left, cutOutRect.top + borderLength); path.lineTo(cutOutRect.left, cutOutRect.top + borderRadius); path.arcToPoint( Offset(cutOutRect.left + borderRadius, cutOutRect.top), radius: Radius.circular(borderRadius), ); path.lineTo(cutOutRect.left + borderLength, cutOutRect.top); // Top right path.moveTo(cutOutRect.right - borderLength, cutOutRect.top); path.lineTo(cutOutRect.right - borderRadius, cutOutRect.top); path.arcToPoint( Offset(cutOutRect.right, cutOutRect.top + borderRadius), radius: Radius.circular(borderRadius), ); path.lineTo(cutOutRect.right, cutOutRect.top + borderLength); // Bottom left path.moveTo(cutOutRect.left, cutOutRect.bottom - borderLength); path.lineTo(cutOutRect.left, cutOutRect.bottom - borderRadius); path.arcToPoint( Offset(cutOutRect.left + borderRadius, cutOutRect.bottom), radius: Radius.circular(borderRadius), clockwise: false, ); path.lineTo(cutOutRect.left + borderLength, cutOutRect.bottom); // Bottom right path.moveTo(cutOutRect.right - borderLength, cutOutRect.bottom); path.lineTo(cutOutRect.right - borderRadius, cutOutRect.bottom); path.arcToPoint( Offset(cutOutRect.right, cutOutRect.bottom - borderRadius), radius: Radius.circular(borderRadius), clockwise: false, ); path.lineTo(cutOutRect.right, cutOutRect.bottom - borderLength); canvas.drawPath(path, borderPaint); // Line in the middle final linePaint = Paint() ..color = borderColor.withOpacity(0.8) ..style = PaintingStyle.stroke ..strokeWidth = 2.0; canvas.drawLine( Offset(cutOutRect.left + 20, cutOutRect.center.dy), Offset(cutOutRect.right - 20, cutOutRect.center.dy), linePaint, ); } @override ShapeBorder scale(double t) { return QrScannerOverlayShape( borderColor: borderColor, borderWidth: borderWidth * t, borderRadius: borderRadius * t, borderLength: borderLength * t, cutOutSize: cutOutSize * t, ); } }