Files
IKAR/lib/backend/api.dart
T

368 lines
12 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/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;
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;
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;
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 = 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() async {
final transport = _transport;
if (transport == null || _state != SessionState.online) return;
try {
await sendRequest(Opcode.ping, {}, 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);
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());
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;
await disconnect();
_dispatcher.dispose();
await _states.close();
await _expired.close();
await _handshakes.close();
await _errors.close();
}
}