Files
Qlyra/lib/core/calls/ws2_signaling.dart
T

286 lines
8.8 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../utils/logger.dart';
import 'conversation_params.dart';
/// Параметры подключения к сигналинг-сокету ws2.
///
/// Строится из двух источников:
/// - входящий звонок: [Ws2Config.fromVcp] (параметры из `vcp` пуша opcode 137);
/// - исходящий звонок: [Ws2Config.fromEndpoint] (`endpoint` из ответа opcode 78,
/// в нём уже вшит токен — дописываем только клиентские параметры).
class Ws2Config {
/// Готовый URL подключения к ws2.
final Uri uri;
/// Внутренний id пользователя в системе звонков.
final int userId;
const Ws2Config({required this.uri, required this.userId});
static const _defaultCapabilities = '3c03f';
static const _appVersion = 'sdk-0.1.16.4';
/// Входящий звонок: из распакованных параметров [ConversationParams].
/// `userId` — часть после `:` в [ConversationParams.turnUser].
factory Ws2Config.fromVcp(
ConversationParams params, {
required String conversationId,
String capabilities = _defaultCapabilities,
String device = 'Komet',
String osVersion = '36',
}) {
final userId =
int.tryParse((params.turnUser ?? '').split(':').last) ?? 0;
final uri = Uri.parse(params.wsEndpoint).replace(queryParameters: {
'userId': '$userId',
'entityType': 'USER',
'conversationId': conversationId,
'token': params.token,
'version': '5',
'capabilities': capabilities,
'device': device,
'platform': 'ANDROID',
'clientType': 'ONE_ME',
'appVersion': _appVersion,
'osVersion': osVersion,
});
return Ws2Config(uri: uri, userId: userId);
}
/// Исходящий звонок: `endpoint` из ответа opcode 78 уже содержит токен и
/// conversationId/userId в query — дописываем клиентские параметры.
factory Ws2Config.fromEndpoint(
String endpoint, {
required int userId,
String capabilities = _defaultCapabilities,
String device = 'Komet',
}) {
final base = Uri.parse(endpoint);
final uri = base.replace(queryParameters: {
...base.queryParameters,
'platform': 'ANDROID',
'version': '5',
'capabilities': capabilities,
'clientType': 'ONE_ME',
'appVersion': _appVersion,
'device': device,
'tgt': 'start',
});
return Ws2Config(uri: uri, userId: userId);
}
}
/// Ошибка, которую вернул сервер в ответе на команду.
class Ws2CommandException implements Exception {
final String command;
final Object? error;
Ws2CommandException(this.command, this.error);
@override
String toString() => 'Ws2CommandException($command): $error';
}
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
///
/// Конверт сообщений (подтверждено захватом `docs/ws2_capture.log`):
/// - запрос: `{"command": ..., ..., "sequence": N}`
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
/// - keepalive: текстовый кадр `ping` → ответ `pong`.
class Ws2Signaling {
final Ws2Config config;
WebSocket? _socket;
int _sequence = 0;
final Map<int, Completer<Map<String, dynamic>>> _pending = {};
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
final _closed = Completer<Object?>();
Ws2Signaling(this.config);
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
/// Завершается, когда сокет закрыт (значение — причина закрытия, если была).
Future<Object?> get done => _closed.future;
bool get isConnected => _socket != null;
Future<void> connect() async {
final socket = await WebSocket.connect(
config.uri.toString(),
headers: {'User-Agent': 'okhttp/4.12.0'},
);
_socket = socket;
socket.listen(
_onFrame,
onError: _onDone,
onDone: () => _onDone(null),
cancelOnError: false,
);
}
void _onFrame(dynamic frame) {
if (frame is String && frame == 'ping') {
_socket?.add('pong');
return;
}
final String text;
if (frame is String) {
text = frame;
} else if (frame is List<int>) {
text = utf8.decode(frame);
} else {
return;
}
Object? decoded;
try {
decoded = jsonDecode(text);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
final label =
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
logger.t('[ws2] ← $label');
logger.t(decoded);
final type = decoded['type'];
if (type == 'response') {
final seq = decoded['sequence'];
if (seq is int) {
final completer = _pending.remove(seq);
if (completer != null && !completer.isCompleted) {
completer.complete(decoded);
}
}
return;
}
if (type == 'notification' || decoded.containsKey('notification')) {
_notifications.add(decoded);
}
}
void _onDone(Object? error) {
for (final c in _pending.values) {
if (!c.isCompleted) c.completeError(error ?? const SocketException('ws2 closed'));
}
_pending.clear();
if (!_closed.isCompleted) _closed.complete(error);
if (!_notifications.isClosed) _notifications.close();
}
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
/// если в ответе есть поле `error`.
Future<Map<String, dynamic>> sendCommand(
String command, {
Map<String, dynamic> extra = const {},
Duration timeout = const Duration(seconds: 15),
}) {
final socket = _socket;
if (socket == null) {
return Future.error(StateError('ws2 не подключён'));
}
final seq = ++_sequence;
final completer = Completer<Map<String, dynamic>>();
_pending[seq] = completer;
socket.add(jsonEncode({'command': command, ...extra, 'sequence': seq}));
return completer.future.timeout(timeout).then((response) {
final error = response['error'];
if (error != null) throw Ws2CommandException(command, error);
return response;
});
}
/// Передаёт SDP (offer/answer) другому участнику.
Future<void> transmitSdp({
required int participantId,
required String type,
required String sdp,
String participantType = 'USER',
int deviceIdx = 0,
String capabilities = '1',
}) {
return sendCommand(
'transmit-data',
extra: {
'participantId': participantId,
'participantType': participantType,
'deviceIdx': deviceIdx,
'data': {
'sdp': {'type': type, 'sdp': sdp},
},
'capabilities': capabilities,
},
);
}
/// Передаёт ICE-кандидата другому участнику (trickle).
Future<void> transmitCandidate({
required int participantId,
required String candidate,
required String sdpMid,
required int sdpMLineIndex,
String participantType = 'USER',
int deviceIdx = 0,
}) {
return sendCommand(
'transmit-data',
extra: {
'participantId': participantId,
'participantType': participantType,
'deviceIdx': deviceIdx,
'data': {
'candidate': {
'candidate': candidate,
'sdpMid': sdpMid,
'sdpMLineIndex': sdpMLineIndex,
},
},
},
);
}
Future<void> changeMediaSettings({
bool isAudioEnabled = true,
bool isVideoEnabled = false,
bool isScreenSharingEnabled = false,
bool isAnimojiEnabled = false,
bool isAudioSharingEnabled = false,
}) {
return sendCommand(
'change-media-settings',
extra: {
'mediaSettings': {
'isVideoEnabled': isVideoEnabled,
'isAudioEnabled': isAudioEnabled,
'isScreenSharingEnabled': isScreenSharingEnabled,
'isAnimojiEnabled': isAnimojiEnabled,
'isAudioSharingEnabled': isAudioSharingEnabled,
},
},
);
}
/// Принять входящий звонок (сторона вызываемого).
Future<void> acceptCall() => sendCommand('accept-call');
Future<void> hangup({String reason = 'HUNGUP'}) =>
sendCommand('hangup', extra: {'reason': reason});
Future<void> close() async {
await _socket?.close();
_socket = null;
}
}