ostp/ostp-flutter/lib/ui/settings_screen.dart

466 lines
18 KiB
Dart

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';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ostp_profile.dart';
class SettingsScreen extends StatefulWidget {
final SharedPreferences prefs;
const SettingsScreen({super.key, required this.prefs});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _localBindCtrl;
late TextEditingController _dnsCtrl;
late TextEditingController _mtuCtrl;
late TextEditingController _domainsCtrl;
late TextEditingController _ipsCtrl;
late TextEditingController _processesCtrl;
late TextEditingController _dnsDomainCtrl;
late TextEditingController _pbkCtrl;
late TextEditingController _sidCtrl;
late TextEditingController _muxSessionsCtrl;
bool _debugMode = false;
String _tunStack = 'ostp';
bool _muxEnabled = false;
bool _tcpFragmentation = false;
List<OstpProfile> _profiles = [];
@override
void initState() {
super.initState();
_loadSettings();
}
void _loadSettings() {
_localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088');
_dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '');
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
_tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false;
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
_dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? '');
_pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? '');
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
_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');
final profilesJson = widget.prefs.getString('profiles_json');
if (profilesJson != null && profilesJson.isNotEmpty) {
try {
final List<dynamic> decoded = jsonDecode(profilesJson);
_profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList();
} catch (e) {
debugPrint('Error loading profiles: $e');
}
}
}
@override
void dispose() {
_saveSettings();
_localBindCtrl.dispose();
_dnsCtrl.dispose();
_mtuCtrl.dispose();
_domainsCtrl.dispose();
_ipsCtrl.dispose();
_processesCtrl.dispose();
_dnsDomainCtrl.dispose();
_pbkCtrl.dispose();
_sidCtrl.dispose();
_muxSessionsCtrl.dispose();
super.dispose();
}
void _saveSettings() {
widget.prefs.setString('local_bind', _localBindCtrl.text.trim());
widget.prefs.setString('dns_server', _dnsCtrl.text.trim());
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation);
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('tun_stack', _tunStack);
widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim());
widget.prefs.setString('tun_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());
final profilesJson = jsonEncode(_profiles.map((e) => e.toJson()).toList());
widget.prefs.setString('profiles_json', profilesJson);
}
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' : (type == 'dns' ? 'dns' : 'udp');
final name = uri.queryParameters['name'] ?? host;
setState(() {
_profiles.add(OstpProfile(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: name,
serverAddr: host,
accessKey: key,
transportMode: transportMode,
));
_saveSettings();
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully')));
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
}
}
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);
},
),
],
),
),
);
}
void _showImportLinkDialog() {
final TextEditingController 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')
),
],
),
);
}
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 ?? '');
String transportMode = profile?.transportMode ?? 'udp';
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,
children: [
TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name')),
TextField(controller: serverCtrl, decoration: const InputDecoration(labelText: 'Server Address (host:port)')),
TextField(controller: keyCtrl, decoration: const InputDecoration(labelText: 'Access Key')),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: transportMode,
decoration: const InputDecoration(labelText: 'Transport'),
items: const [
DropdownMenuItem(value: 'udp', child: Text('UDP')),
DropdownMenuItem(value: 'uot', child: Text('TCP (UoT)')),
],
onChanged: (v) {
if (v != null) setDialogState(() => transportMode = v);
},
),
],
),
),
actions: [
if (!isNew)
TextButton(
onPressed: () {
setState(() {
_profiles.removeWhere((p) => p.id == profile.id);
_saveSettings();
});
Navigator.pop(context);
},
child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () {
setState(() {
if (isNew) {
_profiles.add(OstpProfile(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: nameCtrl.text.trim(),
serverAddr: serverCtrl.text.trim(),
accessKey: keyCtrl.text.trim(),
transportMode: transportMode,
active: true,
));
} else {
profile.name = nameCtrl.text.trim();
profile.serverAddr = serverCtrl.text.trim();
profile.accessKey = keyCtrl.text.trim();
profile.transportMode = transportMode;
}
_saveSettings();
});
Navigator.pop(context);
},
child: const Text('Save')
),
],
),
);
},
);
}
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),
),
),
],
),
);
}
Widget _buildToggle(String title, String subtitle, bool value, ValueChanged<bool> 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) {
setState(() => onChanged(v));
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.secondary,
)
],
),
);
}
@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.add_rounded),
tooltip: 'Add Profile',
onPressed: _showAddProfileMenu,
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
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)),
),
)
else
..._profiles.map((p) => Card(
color: Theme.of(context).colorScheme.surface,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ListTile(
leading: Checkbox(
value: p.active,
onChanged: (val) {
setState(() {
p.active = val ?? false;
_saveSettings();
});
},
),
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)),
trailing: IconButton(
icon: const Icon(Icons.edit, size: 20, color: Colors.white54),
onPressed: () => _showEditProfileDialog(p),
),
onTap: () {
setState(() {
p.active = !p.active;
_saveSettings();
});
},
),
)).toList(),
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),
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: [
_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('TCP Fragmentation', 'Break TLS Hello into small pieces', _tcpFragmentation, (v) => _tcpFragmentation = v),
_buildToggle('Debug Mode', 'Verbose logging', _debugMode, (v) => _debugMode = v),
_buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'),
_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)'),
const SizedBox(height: 24),
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()));
},
),
),
],
),
)
],
),
);
}
}