feat(network): SOCKS5 and HTTP(S) proxy support
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ProxyType { none, socks5, httpConnect }
|
||||
|
||||
class ProxySettings {
|
||||
final ProxyType type;
|
||||
final String host;
|
||||
final int port;
|
||||
final String? username;
|
||||
final String? password;
|
||||
|
||||
const ProxySettings({
|
||||
this.type = ProxyType.none,
|
||||
this.host = '',
|
||||
this.port = 1080,
|
||||
this.username,
|
||||
this.password,
|
||||
});
|
||||
|
||||
bool get isEnabled => type != ProxyType.none && host.isNotEmpty;
|
||||
|
||||
bool get hasCredentials =>
|
||||
username != null &&
|
||||
username!.isNotEmpty &&
|
||||
password != null &&
|
||||
password!.isNotEmpty;
|
||||
}
|
||||
|
||||
abstract class ProxyConfig {
|
||||
static const String _prefType = 'proxy_type';
|
||||
static const String _prefHost = 'proxy_host';
|
||||
static const String _prefPort = 'proxy_port';
|
||||
static const String _prefUsername = 'proxy_username';
|
||||
static const String _prefPassword = 'proxy_password';
|
||||
|
||||
static Future<ProxySettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final typeIndex = prefs.getInt(_prefType) ?? 0;
|
||||
final host = prefs.getString(_prefHost) ?? '';
|
||||
final port = prefs.getInt(_prefPort) ?? 1080;
|
||||
final username = prefs.getString(_prefUsername);
|
||||
final password = prefs.getString(_prefPassword);
|
||||
return ProxySettings(
|
||||
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
|
||||
host: host,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> save(ProxySettings settings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefType, settings.type.index);
|
||||
await prefs.setString(_prefHost, settings.host);
|
||||
await prefs.setInt(_prefPort, settings.port);
|
||||
if (settings.username != null) {
|
||||
await prefs.setString(_prefUsername, settings.username!);
|
||||
} else {
|
||||
await prefs.remove(_prefUsername);
|
||||
}
|
||||
if (settings.password != null) {
|
||||
await prefs.setString(_prefPassword, settings.password!);
|
||||
} else {
|
||||
await prefs.remove(_prefPassword);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefType);
|
||||
await prefs.remove(_prefHost);
|
||||
await prefs.remove(_prefPort);
|
||||
await prefs.remove(_prefUsername);
|
||||
await prefs.remove(_prefPassword);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'proxy_connector.dart';
|
||||
|
||||
enum SocketState { disconnected, connecting, connected }
|
||||
|
||||
/// Обёртка над TCP + TLS сокетом.
|
||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||
class Connection {
|
||||
SecureSocket? _socket;
|
||||
RawSecureSocket? _socket;
|
||||
StreamSubscription<RawSocketEvent>? _subscription;
|
||||
SocketState _state = SocketState.disconnected;
|
||||
|
||||
final _dataController = StreamController<Uint8List>.broadcast();
|
||||
@@ -31,17 +34,39 @@ class Connection {
|
||||
_setState(SocketState.connecting);
|
||||
|
||||
try {
|
||||
final socket = await Socket.connect(host, port);
|
||||
_socket = await SecureSocket.secure(
|
||||
socket,
|
||||
final proxySettings = await ProxyConfig.load();
|
||||
RawSocket rawSocket;
|
||||
|
||||
if (proxySettings.isEnabled) {
|
||||
final connector = ProxyConnector(proxySettings);
|
||||
rawSocket = await connector.connect(host, port);
|
||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||
} else {
|
||||
rawSocket = await RawSocket.connect(host, port);
|
||||
}
|
||||
|
||||
_socket = await RawSecureSocket.secure(
|
||||
rawSocket,
|
||||
host: host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
|
||||
_setState(SocketState.connected);
|
||||
logger.i('Подключено к $host:$port');
|
||||
|
||||
_socket!.listen(
|
||||
(data) => _dataController.add(Uint8List.fromList(data)),
|
||||
_subscription = _socket!.listen(
|
||||
(event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = _socket?.read();
|
||||
if (data != null) {
|
||||
_dataController.add(data);
|
||||
}
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
logger.w('Сокет закрыт сервером');
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
logger.e('Ошибка сокета: $error');
|
||||
disconnect();
|
||||
@@ -62,16 +87,18 @@ class Connection {
|
||||
if (_socket == null || !isConnected) {
|
||||
throw StateError('Нельзя писать: сокет не подключён');
|
||||
}
|
||||
_socket!.add(data);
|
||||
_socket!.write(data);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
final socket = _socket;
|
||||
_socket = null;
|
||||
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.destroy();
|
||||
socket.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси.
|
||||
/// Возвращает [RawSocket], который никогда не слушался —
|
||||
/// его можно передать в [RawSecureSocket.secure].
|
||||
class ProxyConnector {
|
||||
final ProxySettings settings;
|
||||
|
||||
ProxyConnector(this.settings);
|
||||
|
||||
Future<RawSocket> connect(String targetHost, int targetPort) async {
|
||||
switch (settings.type) {
|
||||
case ProxyType.socks5:
|
||||
return _connectSocks5(targetHost, targetPort);
|
||||
case ProxyType.httpConnect:
|
||||
return _connectHttpConnect(targetHost, targetPort);
|
||||
case ProxyType.none:
|
||||
return RawSocket.connect(targetHost, targetPort);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectSocks5(String targetHost, int targetPort) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
|
||||
|
||||
final io = _RawSocketIO(proxySocket);
|
||||
try {
|
||||
// 1. Greeting
|
||||
final useAuth = settings.hasCredentials;
|
||||
if (useAuth) {
|
||||
await io.write([0x05, 0x02, 0x00, 0x02]);
|
||||
} else {
|
||||
await io.write([0x05, 0x01, 0x00]);
|
||||
}
|
||||
|
||||
var response = await io.readExact(2);
|
||||
if (response[0] != 0x05) {
|
||||
throw SocketException(
|
||||
'SOCKS5: неверная версия протокола: ${response[0]}',
|
||||
);
|
||||
}
|
||||
|
||||
final method = response[1];
|
||||
if (method == 0xFF) {
|
||||
throw SocketException(
|
||||
'SOCKS5: сервер отклонил все методы аутентификации',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Аутентификация (RFC 1929)
|
||||
if (method == 0x02) {
|
||||
if (!useAuth) {
|
||||
throw SocketException('SOCKS5: прокси требует аутентификацию');
|
||||
}
|
||||
final usernameBytes = utf8.encode(settings.username!);
|
||||
final passwordBytes = utf8.encode(settings.password!);
|
||||
final authPacket = BytesBuilder()
|
||||
..addByte(0x01)
|
||||
..addByte(usernameBytes.length)
|
||||
..add(usernameBytes)
|
||||
..addByte(passwordBytes.length)
|
||||
..add(passwordBytes);
|
||||
await io.write(authPacket.toBytes());
|
||||
|
||||
final authResponse = await io.readExact(2);
|
||||
if (authResponse[1] != 0x00) {
|
||||
throw SocketException('SOCKS5: аутентификация не пройдена');
|
||||
}
|
||||
logger.i('SOCKS5: аутентификация пройдена');
|
||||
}
|
||||
|
||||
// 3. Connect request
|
||||
final hostBytes = utf8.encode(targetHost);
|
||||
final connectPacket = BytesBuilder()
|
||||
..addByte(0x05) // VER
|
||||
..addByte(0x01) // CMD: CONNECT
|
||||
..addByte(0x00) // RSV
|
||||
..addByte(0x03) // ATYP: domain
|
||||
..addByte(hostBytes.length)
|
||||
..add(hostBytes)
|
||||
..addByte((targetPort >> 8) & 0xFF)
|
||||
..addByte(targetPort & 0xFF);
|
||||
await io.write(connectPacket.toBytes());
|
||||
|
||||
// 4. Reply
|
||||
final reply = await io.readExact(4);
|
||||
if (reply[0] != 0x05) {
|
||||
throw SocketException('SOCKS5: неверная версия в ответе');
|
||||
}
|
||||
if (reply[1] != 0x00) {
|
||||
throw SocketException(
|
||||
'SOCKS5: ошибка подключения, код: ${reply[1]}',
|
||||
);
|
||||
}
|
||||
|
||||
// Пропускаем bind address
|
||||
switch (reply[3]) {
|
||||
case 0x01:
|
||||
await io.readExact(4 + 2);
|
||||
break;
|
||||
case 0x03:
|
||||
final lenBuf = await io.readExact(1);
|
||||
await io.readExact(lenBuf[0] + 2);
|
||||
break;
|
||||
case 0x04:
|
||||
await io.readExact(16 + 2);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен');
|
||||
|
||||
// Создаём локальную пару и проксируем данные
|
||||
return _bridgeToFreshSocket(proxySocket, io);
|
||||
} catch (e) {
|
||||
io.dispose();
|
||||
proxySocket.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectHttpConnect(
|
||||
String targetHost,
|
||||
int targetPort,
|
||||
) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i(
|
||||
'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}',
|
||||
);
|
||||
|
||||
final io = _RawSocketIO(proxySocket);
|
||||
try {
|
||||
final request = StringBuffer()
|
||||
..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n')
|
||||
..write('Host: $targetHost:$targetPort\r\n');
|
||||
|
||||
if (settings.hasCredentials) {
|
||||
final credentials = base64Encode(
|
||||
utf8.encode('${settings.username}:${settings.password}'),
|
||||
);
|
||||
request.write('Proxy-Authorization: Basic $credentials\r\n');
|
||||
}
|
||||
request.write('\r\n');
|
||||
|
||||
await io.write(utf8.encode(request.toString()));
|
||||
|
||||
// Читаем HTTP-ответ до \r\n\r\n
|
||||
final headerBytes = <int>[];
|
||||
while (true) {
|
||||
final byte = await io.readExact(1);
|
||||
headerBytes.add(byte[0]);
|
||||
if (headerBytes.length >= 4 &&
|
||||
headerBytes[headerBytes.length - 4] == 0x0D &&
|
||||
headerBytes[headerBytes.length - 3] == 0x0A &&
|
||||
headerBytes[headerBytes.length - 2] == 0x0D &&
|
||||
headerBytes[headerBytes.length - 1] == 0x0A) {
|
||||
break;
|
||||
}
|
||||
if (headerBytes.length > 8192) {
|
||||
throw SocketException(
|
||||
'HTTP CONNECT: заголовок ответа слишком большой',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final responseStr = utf8.decode(headerBytes, allowMalformed: true);
|
||||
final statusLine = responseStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0;
|
||||
if (statusCode != 200) {
|
||||
throw SocketException(
|
||||
'HTTP CONNECT: прокси вернул статус $statusCode',
|
||||
);
|
||||
}
|
||||
|
||||
logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен');
|
||||
return _bridgeToFreshSocket(proxySocket, io);
|
||||
} catch (e) {
|
||||
io.dispose();
|
||||
proxySocket.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Мост: создаём свежий сокет и проксируем через loopback ─────────────
|
||||
|
||||
/// После handshake proxy-сокет уже прослушан (single-subscription).
|
||||
/// Создаём пару локальных сокетов через loopback и проксируем данные
|
||||
/// между прокси-сокетом и одним концом. Второй конец возвращаем —
|
||||
/// он «свежий» и его можно передать в [RawSecureSocket.secure].
|
||||
Future<RawSocket> _bridgeToFreshSocket(
|
||||
RawSocket proxySocket,
|
||||
_RawSocketIO io,
|
||||
) async {
|
||||
final server = await RawServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
final clientSide = await RawSocket.connect(
|
||||
InternetAddress.loopbackIPv4,
|
||||
server.port,
|
||||
);
|
||||
final serverSide = await server.first;
|
||||
await server.close();
|
||||
|
||||
// proxy → local (через уже имеющуюся подписку _RawSocketIO)
|
||||
io.onData = (data) {
|
||||
serverSide.write(data);
|
||||
};
|
||||
io.onClosed = () {
|
||||
serverSide.shutdown(SocketDirection.send);
|
||||
};
|
||||
|
||||
// local → proxy
|
||||
serverSide.listen((event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = serverSide.read();
|
||||
if (data != null) proxySocket.write(data);
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
proxySocket.shutdown(SocketDirection.send);
|
||||
}
|
||||
});
|
||||
|
||||
// Сливаем данные, буферизованные во время handshake
|
||||
io.flushBuffered();
|
||||
|
||||
logger.i('Прокси-мост через loopback создан');
|
||||
return clientSide;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения.
|
||||
///
|
||||
/// После handshake переключается в режим моста:
|
||||
/// данные из proxy-сокета пересылаются через [onData] в loopback-пару.
|
||||
class _RawSocketIO {
|
||||
final RawSocket _socket;
|
||||
late final StreamSubscription<RawSocketEvent> _sub;
|
||||
|
||||
final _readBuffer = <int>[];
|
||||
Completer<void>? _readWaiter;
|
||||
Completer<void>? _writeWaiter;
|
||||
bool _closed = false;
|
||||
Object? _error;
|
||||
|
||||
/// Коллбэк для данных в режиме моста.
|
||||
void Function(Uint8List data)? onData;
|
||||
|
||||
/// Коллбэк закрытия в режиме моста.
|
||||
void Function()? onClosed;
|
||||
|
||||
_RawSocketIO(this._socket) {
|
||||
_sub = _socket.listen(
|
||||
_onEvent,
|
||||
onError: (Object err) {
|
||||
_error = err;
|
||||
_closed = true;
|
||||
_readWaiter?.completeError(err);
|
||||
_readWaiter = null;
|
||||
_writeWaiter?.completeError(err);
|
||||
_writeWaiter = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onEvent(RawSocketEvent event) {
|
||||
switch (event) {
|
||||
case RawSocketEvent.read:
|
||||
final data = _socket.read();
|
||||
if (data != null) {
|
||||
if (onData != null) {
|
||||
// Режим моста — пересылаем напрямую
|
||||
onData!(data);
|
||||
} else {
|
||||
// Режим handshake — буферизуем
|
||||
_readBuffer.addAll(data);
|
||||
_readWaiter?.complete();
|
||||
_readWaiter = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case RawSocketEvent.write:
|
||||
_writeWaiter?.complete();
|
||||
_writeWaiter = null;
|
||||
break;
|
||||
case RawSocketEvent.readClosed:
|
||||
case RawSocketEvent.closed:
|
||||
_closed = true;
|
||||
onClosed?.call();
|
||||
_readWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
);
|
||||
_readWaiter = null;
|
||||
_writeWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
);
|
||||
_writeWaiter = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает ровно [count] байт.
|
||||
Future<Uint8List> readExact(int count) async {
|
||||
while (_readBuffer.length < count) {
|
||||
if (_error != null) throw _error!;
|
||||
if (_closed) {
|
||||
throw SocketException(
|
||||
'Соединение закрыто '
|
||||
'(ожидали $count байт, получили ${_readBuffer.length})',
|
||||
);
|
||||
}
|
||||
_readWaiter = Completer<void>();
|
||||
await _readWaiter!.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () =>
|
||||
throw SocketException('Тайм-аут при чтении от прокси'),
|
||||
);
|
||||
}
|
||||
final result = Uint8List.fromList(_readBuffer.sublist(0, count));
|
||||
_readBuffer.removeRange(0, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Записывает все байты.
|
||||
Future<void> write(List<int> data) async {
|
||||
var offset = 0;
|
||||
while (offset < data.length) {
|
||||
if (_error != null) throw _error!;
|
||||
if (_closed) throw SocketException('Соединение закрыто при записи');
|
||||
final written = _socket.write(data, offset);
|
||||
if (written > 0) {
|
||||
offset += written;
|
||||
} else {
|
||||
_writeWaiter = Completer<void>();
|
||||
await _writeWaiter!.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () =>
|
||||
throw SocketException('Тайм-аут при записи в прокси'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает данные, оставшиеся в буфере после handshake, в мост.
|
||||
void flushBuffered() {
|
||||
if (_readBuffer.isNotEmpty && onData != null) {
|
||||
onData!(Uint8List.fromList(_readBuffer));
|
||||
_readBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'code_confirmation_screen.dart';
|
||||
import 'select_country_screen.dart';
|
||||
import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import 'spoff_redacted_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
@@ -470,6 +471,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showProxySettingsSheet(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSecurityOptions(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -521,6 +539,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showProxySettingsSheet(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:komet/backend/api.dart';
|
||||
import 'package:komet/core/config/proxy_config.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class ProxySettingsSheet extends StatefulWidget {
|
||||
const ProxySettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ProxySettingsSheet> createState() => _ProxySettingsSheetState();
|
||||
}
|
||||
|
||||
class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
final _hostController = TextEditingController();
|
||||
final _portController = TextEditingController(text: '1080');
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
ProxyType _selectedType = ProxyType.none;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final settings = await ProxyConfig.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedType = settings.type;
|
||||
_hostController.text = settings.host;
|
||||
_portController.text = '${settings.port}';
|
||||
_usernameController.text = settings.username ?? '';
|
||||
_passwordController.text = settings.password ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _apply(AppLocalizations l10n) async {
|
||||
if (_selectedType == ProxyType.none) {
|
||||
return _disable(l10n);
|
||||
}
|
||||
|
||||
final host = _hostController.text.trim();
|
||||
final port = int.tryParse(_portController.text.trim());
|
||||
if (host.isEmpty || port == null || port < 1 || port > 65535) {
|
||||
showCustomNotification(context, l10n.proxyInvalidHostOrPort);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
await ProxyConfig.save(ProxySettings(
|
||||
type: _selectedType,
|
||||
host: host,
|
||||
port: port,
|
||||
username: username.isNotEmpty ? username : null,
|
||||
password: password.isNotEmpty ? password : null,
|
||||
));
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (!mounted) return;
|
||||
if (api.state == SessionState.online) {
|
||||
showCustomNotification(context, l10n.proxySettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disable(AppLocalizations l10n) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ProxyConfig.clear();
|
||||
setState(() => _selectedType = ProxyType.none);
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (!mounted) return;
|
||||
if (api.state == SessionState.online) {
|
||||
showCustomNotification(context, l10n.proxySettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final isActive = _selectedType != ProxyType.none;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.proxySettingsTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Proxy type selector
|
||||
_buildTypeSelector(cs, l10n),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Fields shown only when proxy is enabled
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: isActive
|
||||
? Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: _hostController,
|
||||
label: l10n.proxyHostLabel,
|
||||
hintText: '127.0.0.1',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _portController,
|
||||
label: l10n.proxyPortLabel,
|
||||
hintText: _selectedType == ProxyType.socks5
|
||||
? '1080'
|
||||
: '8080',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _usernameController,
|
||||
label: l10n.proxyUsernameLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: l10n.proxyPasswordLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : () => _apply(l10n),
|
||||
child: Text(
|
||||
isActive ? l10n.proxyApply : l10n.proxyDisable,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) {
|
||||
final labels = {
|
||||
ProxyType.none: l10n.proxyTypeNone,
|
||||
ProxyType.socks5: l10n.proxyTypeSocks5,
|
||||
ProxyType.httpConnect: l10n.proxyTypeHttp,
|
||||
};
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: ProxyType.values.map((type) {
|
||||
final selected = _selectedType == type;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _busy ? null : () => setState(() => _selectedType = type),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labels[type]!,
|
||||
style: GoogleFonts.inter(
|
||||
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
String? hintText,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
bool obscureText = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
obscureText: obscureText,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import 'debug_menu_screen.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
@@ -118,6 +119,28 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
icon: Symbols.notifications_active,
|
||||
label: 'Уведомления и звук',
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.vpn_lock,
|
||||
label: 'Прокси',
|
||||
onTap: () {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.shield_lock,
|
||||
label: 'Подделка данных',
|
||||
|
||||
+14
-1
@@ -41,5 +41,18 @@
|
||||
}
|
||||
},
|
||||
"codeResendSms": "Resend code via SMS",
|
||||
"codeError2faMissing": "Error: missing data for 2FA"
|
||||
"codeError2faMissing": "Error: missing data for 2FA",
|
||||
|
||||
"proxySettingsTitle": "Proxy",
|
||||
"proxyTypeNone": "Disabled",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
"proxyTypeHttp": "HTTP(S)",
|
||||
"proxyHostLabel": "Proxy host",
|
||||
"proxyPortLabel": "Proxy port",
|
||||
"proxyUsernameLabel": "Username (optional)",
|
||||
"proxyPasswordLabel": "Password (optional)",
|
||||
"proxyApply": "Apply and reconnect",
|
||||
"proxyDisable": "Disable proxy",
|
||||
"proxySettingsSaved": "Proxy settings applied",
|
||||
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)"
|
||||
}
|
||||
|
||||
@@ -307,6 +307,78 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Error: missing data for 2FA'**
|
||||
String get codeError2faMissing;
|
||||
|
||||
/// No description provided for @proxySettingsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy'**
|
||||
String get proxySettingsTitle;
|
||||
|
||||
/// No description provided for @proxyTypeNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled'**
|
||||
String get proxyTypeNone;
|
||||
|
||||
/// No description provided for @proxyTypeSocks5.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SOCKS5'**
|
||||
String get proxyTypeSocks5;
|
||||
|
||||
/// No description provided for @proxyTypeHttp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'HTTP(S)'**
|
||||
String get proxyTypeHttp;
|
||||
|
||||
/// No description provided for @proxyHostLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy host'**
|
||||
String get proxyHostLabel;
|
||||
|
||||
/// No description provided for @proxyPortLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy port'**
|
||||
String get proxyPortLabel;
|
||||
|
||||
/// No description provided for @proxyUsernameLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Username (optional)'**
|
||||
String get proxyUsernameLabel;
|
||||
|
||||
/// No description provided for @proxyPasswordLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password (optional)'**
|
||||
String get proxyPasswordLabel;
|
||||
|
||||
/// No description provided for @proxyApply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply and reconnect'**
|
||||
String get proxyApply;
|
||||
|
||||
/// No description provided for @proxyDisable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable proxy'**
|
||||
String get proxyDisable;
|
||||
|
||||
/// No description provided for @proxySettingsSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy settings applied'**
|
||||
String get proxySettingsSaved;
|
||||
|
||||
/// No description provided for @proxyInvalidHostOrPort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a valid proxy host and port (1–65535)'**
|
||||
String get proxyInvalidHostOrPort;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -116,4 +116,41 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get codeError2faMissing => 'Error: missing data for 2FA';
|
||||
|
||||
@override
|
||||
String get proxySettingsTitle => 'Proxy';
|
||||
|
||||
@override
|
||||
String get proxyTypeNone => 'Disabled';
|
||||
|
||||
@override
|
||||
String get proxyTypeSocks5 => 'SOCKS5';
|
||||
|
||||
@override
|
||||
String get proxyTypeHttp => 'HTTP(S)';
|
||||
|
||||
@override
|
||||
String get proxyHostLabel => 'Proxy host';
|
||||
|
||||
@override
|
||||
String get proxyPortLabel => 'Proxy port';
|
||||
|
||||
@override
|
||||
String get proxyUsernameLabel => 'Username (optional)';
|
||||
|
||||
@override
|
||||
String get proxyPasswordLabel => 'Password (optional)';
|
||||
|
||||
@override
|
||||
String get proxyApply => 'Apply and reconnect';
|
||||
|
||||
@override
|
||||
String get proxyDisable => 'Disable proxy';
|
||||
|
||||
@override
|
||||
String get proxySettingsSaved => 'Proxy settings applied';
|
||||
|
||||
@override
|
||||
String get proxyInvalidHostOrPort =>
|
||||
'Enter a valid proxy host and port (1–65535)';
|
||||
}
|
||||
|
||||
@@ -118,4 +118,41 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA';
|
||||
|
||||
@override
|
||||
String get proxySettingsTitle => 'Прокси';
|
||||
|
||||
@override
|
||||
String get proxyTypeNone => 'Выключен';
|
||||
|
||||
@override
|
||||
String get proxyTypeSocks5 => 'SOCKS5';
|
||||
|
||||
@override
|
||||
String get proxyTypeHttp => 'HTTP(S)';
|
||||
|
||||
@override
|
||||
String get proxyHostLabel => 'Хост прокси';
|
||||
|
||||
@override
|
||||
String get proxyPortLabel => 'Порт прокси';
|
||||
|
||||
@override
|
||||
String get proxyUsernameLabel => 'Логин (необязательно)';
|
||||
|
||||
@override
|
||||
String get proxyPasswordLabel => 'Пароль (необязательно)';
|
||||
|
||||
@override
|
||||
String get proxyApply => 'Применить и переподключиться';
|
||||
|
||||
@override
|
||||
String get proxyDisable => 'Отключить прокси';
|
||||
|
||||
@override
|
||||
String get proxySettingsSaved => 'Настройки прокси применены';
|
||||
|
||||
@override
|
||||
String get proxyInvalidHostOrPort =>
|
||||
'Укажите корректный хост и порт прокси (1–65535)';
|
||||
}
|
||||
|
||||
+14
-1
@@ -41,5 +41,18 @@
|
||||
}
|
||||
},
|
||||
"codeResendSms": "Отправить код по SMS",
|
||||
"codeError2faMissing": "Ошибка: отсутствуют данные для 2FA"
|
||||
"codeError2faMissing": "Ошибка: отсутствуют данные для 2FA",
|
||||
|
||||
"proxySettingsTitle": "Прокси",
|
||||
"proxyTypeNone": "Выключен",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
"proxyTypeHttp": "HTTP(S)",
|
||||
"proxyHostLabel": "Хост прокси",
|
||||
"proxyPortLabel": "Порт прокси",
|
||||
"proxyUsernameLabel": "Логин (необязательно)",
|
||||
"proxyPasswordLabel": "Пароль (необязательно)",
|
||||
"proxyApply": "Применить и переподключиться",
|
||||
"proxyDisable": "Отключить прокси",
|
||||
"proxySettingsSaved": "Настройки прокси применены",
|
||||
"proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user