368 lines
12 KiB
Dart
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();
|
|
}
|
|
}
|