Files
IKAR/lib/backend/api.dart
T
sevenhill 1ef2b7420e
Tests / test (push) Canceled after 0s
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
Native crypto / native-crypto (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
Port Komet UI improvements and implement IKAR ghost mode
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
2026-10-07 22:07:05 +03:00

408 lines
14 KiB
Dart

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<SessionState>.broadcast();
final _expired = StreamController<SessionExpiredException>.broadcast();
final _handshakes = StreamController<String>.broadcast();
final _errors = StreamController<String>.broadcast();
final LoginGate _loginGate = LoginGate();
SessionState _state = SessionState.disconnected;
IkarTransport? _transport;
IkarMediaSession? _media;
StreamSubscription<Map<String, dynamic>>? _events;
Timer? _heartbeat;
Timer? _reconnect;
Future<void> Function()? _onReconnect;
Future<bool>? _refreshing;
Future<void> _presenceSync = Future<void>.value();
bool _autoReconnect = false;
bool _disposed = false;
int _epoch = 0;
int _generation = 0;
String? _deviceId;
final _challenges = <String, ({String payload, String scope})>{};
String? spoofScope;
Map<dynamic, dynamic>? 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<CountryName> get registrationCountries => allCountries;
Stream<SessionState> get stateStream => _states.stream;
Stream<SessionExpiredException> get sessionExpiredStream => _expired.stream;
Stream<String> get handshakeSuccessStream => _handshakes.stream;
Stream<String> get errorStream => _errors.stream;
Stream<Packet> 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<void> 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<void> _checkConnection({bool? interactive}) {
final generation = _generation;
final pending = _presenceSync.then((_) async {
if (generation != _generation) return;
await _performConnectionCheck(interactive: interactive);
});
_presenceSync = pending;
return pending;
}
Future<void> _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<Packet> sendRequest(
int opcode,
Map<dynamic, dynamic> 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<dynamic, dynamic>.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<Map<String, dynamic>> _request(
IkarTransport transport,
int opcode,
Map<dynamic, dynamic> 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<void> _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<bool> _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<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async => (await sendRequest(opcode, payload)).payload as Map?;
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async =>
(await sendRequest(opcode, payload)).isOk;
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> 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<void> 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<bool> 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<void> reconnectAndLogin() async {
await disconnect();
await connect();
}
Future<void> reconnectPreLogin() async {
await disconnect();
await connect(allowAutomaticLogin: false);
if (state != SessionState.online)
throw StateError('Не удалось подключиться к ИКАР');
}
Future<void> _clear() async {
_heartbeat?.cancel();
await _events?.cancel();
_events = null;
final transport = _transport;
_transport = null;
_media?.dispose();
_media = null;
await transport?.close();
_loginGate.close();
}
Future<void> disconnect() async {
_generation++;
_autoReconnect = false;
_reconnect?.cancel();
await _clear();
_setState(SessionState.disconnected);
}
Future<void> 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();
}
}