Files
Qlyra/lib/backend/api.dart
T
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

805 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:kusoft/kusoft.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../core/cache/self_presence.dart';
import '../core/config/config.dart';
import '../core/config/countries.dart';
import '../core/config/qlyra_settings.dart';
import '../core/config/proxy_config.dart';
import '../core/protocol/opcode_map.dart';
import '../core/protocol/packet.dart';
import '../core/storage/device_identity.dart';
import '../core/storage/spoofing_service.dart';
import '../core/transport/dispatcher.dart';
import '../core/transport/tls_config.dart';
import '../core/transport/traffic_monitor.dart';
import '../core/transport/vpn_bypass.dart';
import '../core/utils/debug_session_log.dart';
import '../core/utils/logger.dart';
enum SessionState { disconnected, connecting, connected, online }
/// Клиент API.
///
/// Тонкий адаптер над Rust-ядром [KusoftSession] (пакет kusoft): подключение,
/// хэндшейк, пинг и реконнект живут в ядре, здесь — оркестрация жизненного цикла
/// и сохранение прежнего интерфейса для модулей (Packet/пуши/стримы).
class Api {
KusoftSession? _session;
/// Роутер пушей (ответы на запросы ядро матчит само, диспетчер держим только
/// ради registerHandler/pushStream).
final PacketDispatcher _dispatcher = PacketDispatcher();
StreamSubscription<(int, Map<String, dynamic>)>? _pushSub;
StreamSubscription<WireLogEvent>? _wireLogSub;
SessionState _sessionState = SessionState.disconnected;
final _stateController = StreamController<SessionState>.broadcast();
final _sessionExpiredController =
StreamController<SessionExpiredException>.broadcast();
final _handshakeSuccessController = StreamController<String>.broadcast();
final _errorController = StreamController<String>.broadcast();
Map<dynamic, dynamic>? _userAgent;
Map<dynamic, dynamic>? get userAgent => _userAgent;
int? _callsSeed;
String? _deviceId;
String? _callsDevice;
String? _callsOsVersion;
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
String? get callsDevice => _callsDevice;
String? get callsOsVersion => _callsOsVersion;
/// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane).
KusoftSession? get session => _session;
String? spoofScope;
static bool _tzInitialized = false;
List<CountryName>? _registrationCountries;
List<CountryName> get registrationCountries =>
_registrationCountries ?? allCountries;
Stream<SessionState> get stateStream => _stateController.stream;
Stream<SessionExpiredException> get sessionExpiredStream =>
_sessionExpiredController.stream;
Stream<String> get handshakeSuccessStream =>
_handshakeSuccessController.stream;
Stream<String> get errorStream => _errorController.stream;
SessionState get state => _sessionState;
Timer? _livenessTimer;
Timer? _reconnectTimer;
Timer? _connectWatchdog;
int _connectGen = 0;
int _reconnectAttempts = 0;
bool _autoReconnect = false;
int _sessionEpoch = 0;
bool? _lastInteractive;
static const Duration _connectWatchdogTimeout = Duration(seconds: 75);
static const Duration _shouldArmTimeout = Duration(seconds: 5);
static const Duration _endpointTimeout = Duration(seconds: 5);
static const Duration _livenessInterval = Duration(seconds: 5);
int get sessionEpoch => _sessionEpoch;
// Публичное API
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
Future<void> connect() async {
if (_sessionState != SessionState.disconnected) {
logger.i('connect пропущен: состояние ${_sessionState.name}');
return;
}
_autoReconnect = true;
final gen = ++_connectGen;
_setSessionState(SessionState.connecting);
logger.i('connect: старт (поколение $gen)');
_armConnectWatchdog(gen);
try {
bool useBypass;
try {
useBypass = await VpnBypassService.instance.shouldArm().timeout(
_shouldArmTimeout,
);
} catch (e) {
logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN');
useBypass = false;
}
if (gen != _connectGen) return;
({String host, int port, bool trustMincifryCa}) endpoint;
try {
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
} catch (e) {
logger.w('connect: loadEndpoint завис/упал ($e) — дефолтный endpoint');
endpoint = (
host: ServerConfig.defaultHost,
port: ServerConfig.defaultPort,
trustMincifryCa: ServerConfig.defaultTrustMincifryCa,
);
}
if (gen != _connectGen) return;
setTrustMincifryCa(enabled: endpoint.trustMincifryCa);
final (session, wireLog) = await _buildSessionOptions(endpoint);
if (gen != _connectGen) return;
logger.i(
'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass',
);
if (useBypass) {
try {
await VpnBypassService.instance.bind();
} catch (e) {
logger.w('connect: VPN bind не удался ($e)');
}
}
_session = session;
// Подписываемся на wire-лог ядра ДО connect(), чтобы поймать пакеты
// SESSION_INIT-хендшейка (иначе они уходят до listen и теряются).
_wireLogSub = wireLog.listen(_onWireLog);
_pushSub = session.pushesMap().listen(_onPush);
TrafficMonitor.instance.recordEvent(
'connect',
endpoint: '${endpoint.host}:${endpoint.port}',
);
_setSessionState(SessionState.connected);
_reconnectAttempts = 0;
HandshakeInfo info;
try {
logger.i('connect: сокет готов, отправляю хэндшейк');
info = await session.connect();
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(e, phase: 'Ошибка хэндшейка');
return;
} finally {
if (useBypass) {
try {
await VpnBypassService.instance.restoreDefault();
} catch (_) {}
}
}
if (gen != _connectGen) return;
_callsSeed = info.callsSeed?.toInt();
_registrationCountries = _parseRegistrationCountries(info.payloadMap);
_sessionState = SessionState.online;
_sessionEpoch++;
_cancelConnectWatchdog();
_startLiveness();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(info.deviceName ?? 'Unknown');
}
} catch (e, st) {
logger.e('connect: непредвиденная ошибка: $e\n$st');
if (gen == _connectGen) await _resetStuckConnect(gen);
}
}
void _armConnectWatchdog(int gen) {
_connectWatchdog?.cancel();
_connectWatchdog = Timer(_connectWatchdogTimeout, () {
if (gen != _connectGen) return;
if (_sessionState == SessionState.online ||
_sessionState == SessionState.disconnected) {
return;
}
logger.e(
'connect: watchdog ${_connectWatchdogTimeout.inSeconds}с — застряли в '
'${_sessionState.name}, принудительный сброс',
);
unawaited(_resetStuckConnect(gen));
});
}
void _cancelConnectWatchdog() {
_connectWatchdog?.cancel();
_connectWatchdog = null;
}
Future<void> _resetStuckConnect(int gen) async {
if (gen != _connectGen) return;
_connectGen++;
_cancelConnectWatchdog();
_cleanup();
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
Future<void> _handleConnectFailure(
Object error, {
required String phase,
}) async {
logger.e('$phase: $error');
_cancelConnectWatchdog();
if (_sessionState != SessionState.disconnected) {
_cleanup();
_setSessionState(SessionState.disconnected);
_scheduleReconnect();
}
}
/// Отключается без автореконнекта.
Future<void> disconnect() async {
_autoReconnect = false;
_connectGen++;
_reconnectTimer?.cancel();
_cleanup();
_setSessionState(SessionState.disconnected);
}
void wakeUp() {
if (!_autoReconnect) return;
switch (_sessionState) {
case SessionState.disconnected:
_reconnectAttempts = 0;
_reconnectTimer?.cancel();
unawaited(connect());
case SessionState.connecting:
case SessionState.connected:
_reconnectAttempts = 0;
case SessionState.online:
unawaited(_probeLiveness());
}
}
/// Отправляет запрос и ждёт ответ от сервера.
Future<Packet> sendRequest(
int opcode,
Map<dynamic, dynamic> payload, {
bool silent = false,
}) async {
final session = _session;
if (session == null) {
throw StateError('Нет соединения (${Opcode.name(opcode)})');
}
// Лог запроса/ответа ведётся из wire-лога ядра (_onWireLog) по настоящему
// проводному seq, поэтому здесь ничего не пишем.
final KusoftResponse resp = await session
.requestMapFull(opcode, Map<String, dynamic>.from(payload))
.timeout(
ServerConfig.requestTimeout,
onTimeout: () =>
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
);
final packet = Packet(
cmd: resp.cmd,
opcode: resp.opcode,
payload: resp.payload,
);
if (packet.isError) {
if (isSessionExpiredPayload(packet.payload)) {
final ex = SessionExpiredException(
messageFromErrorPayload(packet.payload),
);
_sessionExpiredController.add(ex);
throw ex;
}
final text = _serverErrorText(packet.payload);
if (text != null && !silent) _errorController.add(text);
final err = PacketError(
messageFromErrorPayload(packet.payload),
errorKey: resp.errorKey,
);
throw err;
}
return packet;
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
if (!response.isOk || response.payload is! Map) return null;
return response.payload as Map<dynamic, dynamic>;
}
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
final response = await sendRequest(opcode, payload);
return response.isOk;
}
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
throwIfPacketError(response);
return response;
}
/// Вешает обработчик на пуши с указанным опкодом.
void registerPushHandler(int opcode, void Function(Packet) handler) {
_dispatcher.registerHandler(opcode, handler);
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
Future<void> dispose() async {
_autoReconnect = false;
_reconnectTimer?.cancel();
_cleanup();
_dispatcher.dispose();
await _stateController.close();
await _sessionExpiredController.close();
await _handshakeSuccessController.close();
await _errorController.close();
}
// Внутрянка
/// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет
/// [_userAgent] и [_deviceId] для геттеров.
Future<(KusoftSession, Stream<WireLogEvent>)> _buildSessionOptions(
({String host, int port, bool trustMincifryCa}) endpoint,
) async {
final deviceInfo = DeviceInfoPlugin();
String deviceType = 'ANDROID';
String osVersion = '';
String deviceName = 'Unknown';
String architecture = 'arm64-v8a';
String appVersion = SpoofingService.hardcodedAppVersion;
int buildNumber = SpoofingService.hardcodedBuildNumber;
String screen = '420dpi 420dpi 1080x2340';
if (!_tzInitialized) {
tz.initializeTimeZones();
_tzInitialized = true;
}
final timeZoneName = await FlutterTimezone.getLocalTimezone();
String timezone = timeZoneName.identifier;
String locale = 'ru';
String deviceLocale = Platform.localeName.substring(0, 2);
String deviceId = await DeviceIdentity.deviceId();
String pushDeviceType = 'GCM';
String instanceId = await DeviceIdentity.instanceId();
int clientSessionId = DeviceIdentity.clientSessionId;
String? androidManufacturer;
String? androidModel;
int? androidSdkInt;
if (Platform.isLinux) {
final linuxInfo = await deviceInfo.linuxInfo;
osVersion = linuxInfo.name;
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
osVersion = iosInfo.systemVersion;
deviceName = iosInfo.utsname.machine;
} else if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
osVersion = 'Android ${androidInfo.version.release}';
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
androidManufacturer = androidInfo.manufacturer;
androidModel = androidInfo.model;
androidSdkInt = androidInfo.version.sdkInt;
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
osVersion = windowsInfo.productName;
}
String? spoofUserAgent;
final spoofed = await SpoofingService.getSpoofedSessionData(
scope: spoofScope,
);
if (spoofed != null) {
spoofUserAgent = spoofed['user_agent'] as String?;
final sDeviceType = spoofed['device_type'] as String?;
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
final sDeviceName = spoofed['device_name'] as String?;
if (sDeviceName != null && sDeviceName.isNotEmpty) {
deviceName = sDeviceName;
}
final sOsVersion = spoofed['os_version'] as String?;
if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion;
final sScreen = spoofed['screen'] as String?;
if (sScreen != null && sScreen.isNotEmpty) screen = sScreen;
final sTimezone = spoofed['timezone'] as String?;
if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone;
final sLocale = spoofed['locale'] as String?;
if (sLocale != null && sLocale.isNotEmpty) {
locale = sLocale;
deviceLocale = sLocale.split(RegExp(r'[-_]')).first;
}
final sDeviceLocale = spoofed['device_locale'] as String?;
if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) {
deviceLocale = sDeviceLocale;
}
final sDeviceId = spoofed['device_id'] as String?;
if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId;
appVersion = (spoofed['app_version'] as String?) ?? appVersion;
architecture = (spoofed['arch'] as String?) ?? architecture;
final sBuild = spoofed['build_number'];
if (sBuild is int) {
buildNumber = sBuild;
} else if (sBuild is String) {
buildNumber = int.tryParse(sBuild) ?? buildNumber;
}
final sPushType = spoofed['push_device_type'] as String?;
if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType;
final sInstanceId = spoofed['instance_id'] as String?;
if (sInstanceId != null && sInstanceId.isNotEmpty) {
instanceId = sInstanceId;
}
final sClientSession = spoofed['client_session_id'];
if (sClientSession is int) clientSessionId = sClientSession;
}
_callsDevice = _resolveCallsDevice(
spoofed: spoofed != null,
deviceName: deviceName,
spoofUserAgent: spoofUserAgent,
manufacturer: androidManufacturer,
model: androidModel,
);
_callsOsVersion = _resolveCallsOsVersion(
spoofed: spoofed != null,
osVersion: osVersion,
sdkInt: androidSdkInt,
);
_userAgent = {
'deviceType': deviceType,
'appVersion': appVersion,
'osVersion': osVersion,
'timezone': timezone,
'screen': screen,
'pushDeviceType': pushDeviceType,
'arch': architecture,
'locale': locale,
'buildNumber': buildNumber,
'deviceName': deviceName,
'deviceLocale': deviceLocale,
};
_deviceId = deviceId;
final insecureTls = await TlsConfig.isInsecureAllowed();
final proxy = await _buildProxyUrl();
return openSessionWithWireLog(
host: endpoint.host,
port: endpoint.port,
deviceId: deviceId,
instanceId: instanceId,
appVersion: appVersion,
buildNumber: buildNumber,
deviceType: deviceType,
osVersion: osVersion,
timezone: timezone,
screen: screen,
pushDeviceType: pushDeviceType,
arch: architecture,
locale: locale,
deviceName: deviceName,
deviceLocale: deviceLocale,
clientSessionId: clientSessionId,
pingIntervalSecs: ServerConfig.pingInterval.inSeconds,
pingInteractive: !QlyraSettings.ghostMode.value,
autoReconnect: false,
insecureTls: insecureTls,
proxy: proxy,
);
}
static String? _resolveCallsDevice({
required bool spoofed,
required String deviceName,
String? spoofUserAgent,
String? manufacturer,
String? model,
}) {
if (!spoofed &&
manufacturer != null &&
manufacturer.isNotEmpty &&
model != null &&
model.isNotEmpty) {
return '$manufacturer/$model';
}
final parts = deviceName.trim().split(RegExp(r'\s+'))
..removeWhere((p) => p.isEmpty);
if (parts.isEmpty) return null;
final fallbackModel = parts.length > 1
? parts.sublist(1).join(' ')
: parts.first;
return '${parts.first}/'
'${_modelFromUserAgent(spoofUserAgent) ?? fallbackModel}';
}
static String? _modelFromUserAgent(String? userAgent) {
if (userAgent == null || userAgent.isEmpty) return null;
final match = RegExp(r'Android\s+[\d.]+;\s*([^;)]+)').firstMatch(userAgent);
final model = match
?.group(1)
?.replaceFirst(RegExp(r'\s+Build/.*$'), '')
.trim();
return model == null || model.isEmpty ? null : model;
}
static String _resolveCallsOsVersion({
required bool spoofed,
required String osVersion,
int? sdkInt,
}) {
if (!spoofed && sdkInt != null && sdkInt > 0) return '$sdkInt';
final release = RegExp(
r'^Android\s+(\d+)',
).firstMatch(osVersion.trim())?.group(1);
return '${_androidSdkForRelease(int.tryParse(release ?? ''))}';
}
static int _androidSdkForRelease(int? release) => switch (release) {
null => 34,
<= 9 => 28,
10 => 29,
11 => 30,
12 => 31,
13 => 33,
14 => 34,
15 => 35,
_ => 36,
};
static Future<String?> _buildProxyUrl() async {
final p = await ProxyConfig.load();
if (!p.isEnabled) return null;
final scheme = p.type == ProxyType.socks5 ? 'socks5h' : 'http';
final auth = p.hasCredentials
? '${Uri.encodeComponent(p.username!)}:'
'${Uri.encodeComponent(p.password!)}@'
: '';
return '$scheme://$auth${p.host}:${p.port}';
}
void _onPush((int, Map<String, dynamic>) event) {
final packet = Packet(
cmd: CmdType.push,
opcode: event.$1,
payload: event.$2,
);
// Учёт трафика пушей ведётся из wire-лога ядра (_onWireLog).
_dispatcher.dispatch(packet);
}
/// Единый источник лога трафика: ядро отдаёт сюда каждый пакет обеих сторон —
/// включая SESSION_INIT-хендшейк и пинги — с настоящим проводным seq. Раньше
/// лог вёлся вручную из [sendRequest] по локальному счётчику, из-за чего
/// хендшейк/пинги в дамп не попадали, а seq был смещён относительно провода.
void _onWireLog(WireLogEvent e) {
final payload = _decodeWireJson(e.json);
final cmd = _wireCmdCode(e.cmd);
if (e.direction == 'out') {
DebugSessionLog.instance.recordRequest(e.opcode, e.seq, payload);
TrafficMonitor.instance.recordOutgoing(e.opcode, payload, e.seq, 0);
return;
}
// Входящие: ответы матчатся по seq, пуши идут только в монитор трафика.
if (e.cmd != 'push') {
DebugSessionLog.instance.recordResponse(e.seq, cmd, payload);
}
TrafficMonitor.instance.recordIncoming(
Packet(cmd: cmd, seq: e.seq, opcode: e.opcode, payload: payload),
0,
);
}
static int _wireCmdCode(String cmd) {
switch (cmd) {
case 'ok':
return CmdType.ok;
case 'not_found':
return CmdType.notFound;
case 'error':
return CmdType.error;
default:
return CmdType.request; // 'request' и 'push'
}
}
static dynamic _decodeWireJson(String json) {
try {
return jsonDecode(json);
} catch (_) {
return json;
}
}
void _setSessionState(SessionState state) {
if (_sessionState == state) return;
_sessionState = state;
_stateController.add(state);
logger.i('Сессия: ${state.name}');
}
void _onDisconnected() {
_connectGen++;
_cleanup();
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
/// Пробный запрос-пинг: если не ответил — форсируем реконнект.
Future<void> _probeLiveness() async {
if (_sessionState != SessionState.online) return;
final session = _session;
if (session == null) return;
final epoch = _sessionEpoch;
try {
await session
.requestMapFull(Opcode.ping, {
'interactive': !QlyraSettings.ghostMode.value,
})
.timeout(const Duration(seconds: 6));
} catch (_) {
if (_sessionEpoch != epoch || _sessionState != SessionState.online) {
return;
}
logger.w('Пробный пинг не прошёл — принудительный реконнект');
await _forceReconnect();
}
}
Future<void> _forceReconnect() async {
_connectGen++;
_cleanup();
_reconnectAttempts = 0;
_reconnectTimer?.cancel();
_setSessionState(SessionState.disconnected);
if (_autoReconnect) unawaited(connect());
}
void _cleanup() {
_cancelConnectWatchdog();
_livenessTimer?.cancel();
_livenessTimer = null;
_pushSub?.cancel();
_pushSub = null;
_wireLogSub?.cancel();
_wireLogSub = null;
_lastInteractive = null;
final session = _session;
_session = null;
if (session != null) {
try {
session.disconnect();
} catch (_) {}
}
_dispatcher.clearPending();
_handshakeSuccessController.add('disconnected');
}
Future<void> reconnectAndLogin() async {
await connect();
}
Future<void> Function()? _onReconnectCallback;
void setReconnectCallback(Future<void> Function() callback) {
_onReconnectCallback = callback;
}
/// Поллит состояние ядра (стрима состояний нет) — детект разрыва, плюс
/// синхронизация interactive-флага пинга и присутствия.
void _startLiveness() {
_livenessTimer?.cancel();
_lastInteractive = !QlyraSettings.ghostMode.value;
_livenessTimer = Timer.periodic(_livenessInterval, (_) => _tickLiveness());
}
void _tickLiveness() {
final session = _session;
if (session == null || _sessionState != SessionState.online) return;
final st = session.state();
if (st != 'online' && st != 'connected') {
logger.w('kusoft сессия "$st" — реконнект');
_onDisconnected();
return;
}
final interactive = !QlyraSettings.ghostMode.value;
if (interactive != _lastInteractive) {
_lastInteractive = interactive;
try {
session.setPingInteractive(interactive: interactive);
} catch (_) {}
}
if (interactive) {
SelfPresence.markOnline();
} else {
SelfPresence.markOfflineFromPing();
}
}
void sendPing({required bool interactive}) {
final session = _session;
if (session != null && _sessionState == SessionState.online) {
try {
session.setPingInteractive(interactive: interactive);
} catch (_) {}
_lastInteractive = interactive;
if (interactive) {
SelfPresence.markOnline();
} else {
SelfPresence.markOfflineFromPing();
}
}
}
static String? _serverErrorText(dynamic payload) {
if (payload is! Map) return null;
for (final key in ['localizedMessage', 'title']) {
final v = payload[key];
if (v is String && v.trim().isNotEmpty) return v.trim();
}
return null;
}
static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
if (payload is! Map) return null;
final raw = payload['reg-country-code'];
if (raw is! List || raw.isEmpty) return null;
final codes = <String>[];
for (final e in raw) {
if (e is String && e.isNotEmpty) codes.add(e.toUpperCase());
}
if (codes.isEmpty) return null;
var list = countriesInServerOrder(codes);
if (list.isEmpty) return null;
final loc = payload['location'];
if (loc is String && loc.length == 2) {
final home = countriesByCode[loc.toUpperCase()];
if (home != null && !list.any((c) => c.code == home.code)) {
list = [home, ...list];
}
}
return list;
}
void _scheduleReconnect() {
final delaySec = (2 * (1 << _reconnectAttempts.clamp(0, 3))).clamp(2, 15);
_reconnectAttempts++;
logger.i('Реконнект через $delaySecс (попытка $_reconnectAttempts)');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(seconds: delaySec), connect);
}
}