feat: звонки 1:1 (аудио) — WebRTC + ws2-сигналинг
Реализованы голосовые звонки 1:1 поверх инфраструктуры VK/OK
(flutter_webrtc + сигналинг ws2 wss://videowebrtc.okcdn.ru). Протокол
восстановлен реверсом захваченных пакетов и декомпиляцией
ru.ok.android.externcalls.sdk.
Ядро (lib/core/calls/):
- conversation_params.dart — декодер vcp (LZ4-блок), с unit-тестом
- ws2_signaling.dart — клиент сигналинга ws2 (transmit-data, ICE,
accept-call, hangup, change-media-settings, ping/pong)
- call_session.dart — WebRTC-контроллер для звонящего и вызываемого
- call_controller.dart — глобальный оркестратор: пуш opcode 137,
исходящие, приём/отклонение, управление активной сессией
Бэкенд:
- CallsModule.initiateCall — инициация исходящего (opcode 78)
- общий LZ4-block декодер вынесен в core/protocol/lz4_block.dart
(используется и транспортом, и vcp)
UI / обвязка:
- CallScreen переписан под живой CallSession (+ кнопка «свернуть»
без завершения звонка, возврат повторным нажатием 📞)
- кнопка звонка в шапке диалога
- инициализация CallController на логине, показ входящего по пушу
- дев-тумблер: принудительный сигнал состояния микрофона без
изменения реального аудиотрека
Прочее:
- зависимость flutter_webrtc ^0.12.5
- Android: RECORD_AUDIO / MODIFY_AUDIO_SETTINGS / BLUETOOTH
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user