import 'dart:async'; import 'dart:io'; import 'package:http/http.dart' as http; import '../core/config/config.dart'; import '../core/config/countries.dart'; import '../core/config/qlyra_settings.dart'; import '../core/utils/app_foreground.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/storage/token_storage.dart'; import '../core/storage/ikar_device_key.dart'; import '../core/transport/dispatcher.dart'; import '../core/utils/logger.dart'; import 'ikar_transport.dart'; import 'ikar_media_session.dart'; import 'login_gate.dart'; enum SessionState { disconnected, connecting, connected, online } /// Client for ikar_server. class Api { Api({IkarTransport Function(Uri)? transportFactory}) : _transportFactory = transportFactory ?? IkarTransport.new { QlyraSettings.ghostMode.addListener(_presenceChanged); AppForeground.notifier.addListener(_presenceChanged); } final IkarTransport Function(Uri) _transportFactory; final PacketDispatcher _dispatcher = PacketDispatcher(); final _states = StreamController.broadcast(); final _expired = StreamController.broadcast(); final _handshakes = StreamController.broadcast(); final _errors = StreamController.broadcast(); final LoginGate _loginGate = LoginGate(); SessionState _state = SessionState.disconnected; IkarTransport? _transport; IkarMediaSession? _media; StreamSubscription>? _events; Timer? _heartbeat; Timer? _reconnect; Future Function()? _onReconnect; Future? _refreshing; Future _presenceSync = Future.value(); bool _autoReconnect = false; bool _disposed = false; int _epoch = 0; int _generation = 0; String? _deviceId; final _challenges = {}; String? spoofScope; Map? get userAgent => { 'deviceType': 'ANDROID', 'deviceName': 'ИКАР', 'appVersion': '1.0.4', }; int? get callsSeed => null; String? get deviceId => _deviceId; String? get callsDevice => 'ИКАР'; String? get callsOsVersion => 'Android'; String get architecture => 'arm64-v8a'; IkarMediaSession? get session => _media; SessionState get state => _state; int get sessionEpoch => _epoch; bool get hasLoginAttempt => _loginGate.loginAttempted; List get registrationCountries => allCountries; Stream get stateStream => _states.stream; Stream get sessionExpiredStream => _expired.stream; Stream get handshakeSuccessStream => _handshakes.stream; Stream get errorStream => _errors.stream; Stream get pushStream => _dispatcher.pushStream; bool get _interactive => !QlyraSettings.ghostMode.value && AppForeground.value; void _presenceChanged() { final transport = _transport; if (transport != null) transport.interactive = _interactive; if (_state == SessionState.online) unawaited(_checkConnection()); } void _setState(SessionState state) { _state = state; if (!_states.isClosed) _states.add(state); } Future connect({bool allowAutomaticLogin = true}) async { if (_disposed || _state != SessionState.disconnected) return; final generation = ++_generation; _autoReconnect = true; _loginGate.close(); _setState(SessionState.connecting); try { final endpoint = await ServerConfig.loadEndpoint(); final baseUri = Uri( scheme: 'https', host: endpoint.host, port: endpoint.port, ); final transport = _transportFactory(baseUri); transport.interactive = _interactive; _transport = transport; final ping = await transport.request(Opcode.ping, {}); if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен'); if (generation != _generation) { await transport.close(); return; } _media = IkarMediaSession(baseUri); _events = transport.events.listen( (event) => _dispatcher.dispatch( Packet( cmd: CmdType.push, opcode: (event['opcode'] as num).toInt(), payload: event['payload'], ), ), ); _epoch++; _state = SessionState.online; if (allowAutomaticLogin && _onReconnect != null) await _onReconnect!(); _loginGate.open(); _setState(SessionState.online); _handshakes.add('ИКАР'); _heartbeat = Timer.periodic( const Duration(seconds: 10), (_) => unawaited(_checkConnection()), ); } catch (error) { if (generation != _generation) return; logger.w('IKAR reconnect failed: $error'); await _clear(); _setState(SessionState.disconnected); _scheduleReconnect(); } } Future _checkConnection({bool? interactive}) { final generation = _generation; final pending = _presenceSync.then((_) async { if (generation != _generation) return; await _performConnectionCheck(interactive: interactive); }); _presenceSync = pending; return pending; } Future _performConnectionCheck({bool? interactive}) async { final transport = _transport; if (transport == null || _state != SessionState.online) return; try { final active = (interactive ?? AppForeground.value) && !QlyraSettings.ghostMode.value; transport.interactive = active; await sendRequest(Opcode.ping, {'interactive': active}, silent: true); if (transport.token != null && !transport.eventsConnected) await transport.connectEvents(); } catch (_) { if (identical(_transport, transport)) { await _clear(); _setState(SessionState.disconnected); _scheduleReconnect(); } } } void _scheduleReconnect() { if (!_autoReconnect || _disposed) return; _reconnect?.cancel(); _reconnect = Timer(const Duration(seconds: 5), () => unawaited(connect())); } Future sendRequest( int opcode, Map payload, { bool silent = false, void Function()? beforeSend, }) async { final initial = _transport; if (initial == null && _state != SessionState.connecting) throw StateError('Нет соединения (${Opcode.name(opcode)})'); if (opcode != Opcode.login) await _loginGate.wait(ServerConfig.requestTimeout, Opcode.name(opcode)); final transport = _transport; if (transport == null) throw StateError('Нет соединения (${Opcode.name(opcode)})'); if (beforeSend != null && !identical(transport, initial)) throw StateError('Сессия изменилась до отправки'); beforeSend?.call(); final body = Map.from(payload); transport.interactive = _interactive; if (opcode == Opcode.login || opcode == Opcode.ping) { body['interactive'] = (body['interactive'] as bool? ?? AppForeground.value) && !QlyraSettings.ghostMode.value; transport.interactive = body['interactive'] as bool; } if (opcode == Opcode.authRequest) { final identity = await IkarDeviceKey.identity(body['phone'].toString()); _deviceId = identity['installationId']; body.addAll(identity); } if (opcode == Opcode.auth && _challenges.containsKey(body['token'])) { final challenge = _challenges.remove(body['token'])!; body['deviceSignature'] = await IkarDeviceKey.sign( challenge.payload, challenge.scope, ); } if (opcode == Opcode.login || opcode == Opcode.authConfirm) transport.token = body['token'] as String? ?? transport.token; if (opcode == Opcode.login) _loginGate.noteLoginSent(); var result = await _request(transport, opcode, body); if (result['cmd'] == 3 && (result['payload'] as Map?)?['message'] == 'FAIL_LOGIN_TOKEN') { if (await _refreshToken(transport)) result = await _request(transport, opcode, body); } final packet = Packet( cmd: (result['cmd'] as num?)?.toInt() ?? 3, opcode: opcode, payload: result['payload'], ); if (opcode == Opcode.login) _loginGate.noteLoginAnswer(ok: packet.isOk); if (packet.isError) { if (isSessionExpiredPayload(packet.payload)) { final error = SessionExpiredException( messageFromErrorPayload(packet.payload), ); _expired.add(error); throw error; } final message = messageFromErrorPayload(packet.payload); if (!silent) _errors.add(message); throw PacketError( message, errorKey: result['errorKey'] as String? ?? (packet.payload as Map?)?['error'] as String?, ); } if (packet.payload is Map) { final data = packet.payload as Map; if (opcode == Opcode.authRequest && data['signaturePayload'] is String) _challenges[data['token'] as String] = ( payload: data['signaturePayload'] as String, scope: body['phone'].toString(), ); await _saveIssuedTokens(data, transport); } if (opcode == Opcode.login) await transport.connectEvents(); return packet; } Future> _request( IkarTransport transport, int opcode, Map body, ) async { try { return await transport.request(opcode, body); } catch (error) { if (error is SocketException || error is HttpException || error is HandshakeException || error is TimeoutException || error is http.ClientException) { if (identical(_transport, transport) && _state != SessionState.disconnected) { _setState(SessionState.disconnected); await _clear(); _scheduleReconnect(); } } rethrow; } } Future _saveIssuedTokens(Map data, IkarTransport transport) async { final attrs = data['tokenAttrs']; Map? issued; if (attrs is Map) issued = (attrs['LOGIN'] ?? attrs['REGISTER']) as Map?; if (data['token'] is String && data['refreshToken'] is String) issued = data; if (issued == null) return; final token = issued['token'] as String?; final refresh = issued['refreshToken'] as String?; final id = (data['accountId'] as num?)?.toInt() ?? ((data['profile'] as Map?)?['contact'] as Map?)?['id'] as int?; if (token != null) transport.token = token; if (id != null && token != null) { await TokenStorage.saveToken(token, id); if (refresh != null) await TokenStorage.writeSecure('ikar_refresh_$id', refresh); } } Future _refreshToken(IkarTransport transport) async { if (_refreshing != null) return _refreshing!; final future = () async { final id = await TokenStorage.getActiveAccountId(); if (id == null) return false; final refresh = await TokenStorage.readSecure('ikar_refresh_$id'); if (refresh == null) return false; final session = await transport.refresh(refresh); if (session == null) return false; final access = session['accessToken'] as String?; final nextRefresh = session['refreshToken'] as String?; if (access == null || nextRefresh == null) return false; transport.token = access; await TokenStorage.saveToken(access, id); await TokenStorage.writeSecure('ikar_refresh_$id', nextRefresh); return true; }(); _refreshing = future; try { return await future; } finally { if (identical(_refreshing, future)) _refreshing = null; } } Future?> sendRequestMap( int opcode, Map payload, ) async => (await sendRequest(opcode, payload)).payload as Map?; Future sendRequestOk(int opcode, Map payload) async => (await sendRequest(opcode, payload)).isOk; Future sendRequestOrThrow( int opcode, Map payload, ) => sendRequest(opcode, payload); void registerPushHandler(int opcode, void Function(Packet) handler) => _dispatcher.registerHandler(opcode, handler); void unregisterPushHandler(int opcode) => _dispatcher.unregisterHandler(opcode); void setReconnectCallback(Future Function() callback) => _onReconnect = callback; void wakeUp() { if (_state == SessionState.disconnected) unawaited(connect()); else unawaited(_checkConnection()); } void sendPing({required bool interactive}) => unawaited(_checkConnection(interactive: interactive)); Future reconnectToEndpoint({ String? host, int? port, bool trustMincifryCa = false, }) async { if (host != null && port != null) { await ServerConfig.saveEndpoint( host: host, port: port, trustMincifryCa: false, ); } await reconnectAndLogin(); return _state == SessionState.online; } Future reconnectAndLogin() async { await disconnect(); await connect(); } Future reconnectPreLogin() async { await disconnect(); await connect(allowAutomaticLogin: false); if (state != SessionState.online) throw StateError('Не удалось подключиться к ИКАР'); } Future _clear() async { _heartbeat?.cancel(); await _events?.cancel(); _events = null; final transport = _transport; _transport = null; _media?.dispose(); _media = null; await transport?.close(); _loginGate.close(); } Future disconnect() async { _generation++; _autoReconnect = false; _reconnect?.cancel(); await _clear(); _setState(SessionState.disconnected); } Future dispose() async { _disposed = true; QlyraSettings.ghostMode.removeListener(_presenceChanged); AppForeground.notifier.removeListener(_presenceChanged); await disconnect(); _dispatcher.dispose(); await _states.close(); await _expired.close(); await _handshakes.close(); await _errors.close(); } }