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:
klockky
2026-06-10 18:21:27 +03:00
parent 4c0dd48a3a
commit cf154b006b
16 changed files with 1489 additions and 180 deletions
+94
View File
@@ -1,10 +1,35 @@
// Backend module for parsing calls from Komet platform
import 'dart:convert';
import 'dart:math';
import 'contacts.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
enum CallStatus { missed, canceled, outgoing, incoming }
/// Параметры подключения для исходящего звонка (ответ opcode 78).
class OutgoingCallParams {
final String conversationId;
/// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
final String endpoint;
/// Наш id в системе звонков (`internalCallerParams.id.internal`).
final int callsUserId;
final int peerExternalId;
final bool isVideo;
const OutgoingCallParams({
required this.conversationId,
required this.endpoint,
required this.callsUserId,
required this.peerExternalId,
required this.isVideo,
});
}
class CallLogEntry {
final String id;
final int accountId;
@@ -32,6 +57,75 @@ class CallsModule {
CallsModule(this._api);
/// Инициирует исходящий 1:1 звонок (opcode 78).
Future<OutgoingCallParams> initiateCall(
int calleeId, {
bool isVideo = false,
}) async {
final conversationId = _uuidV4();
final internalParams = jsonEncode({
'deviceId': _api.deviceId ?? '',
'sdkVersion': '2.8.9',
'clientAppKey': _clientAppKey(),
'platform': 'ANDROID',
'protocolVersion': 5,
'domainId': '',
'capabilities': '3c03f',
});
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
'conversationId': conversationId,
'calleeIds': [calleeId],
'internalParams': internalParams,
'isVideo': isVideo,
});
if (!response.isOk || response.payload is! Map) {
throw Exception('initiateCall: bad response');
}
final payload = response.payload as Map;
final icpRaw = payload['internalCallerParams'];
final icp = icpRaw is String
? jsonDecode(icpRaw) as Map<dynamic, dynamic>
: const <dynamic, dynamic>{};
final endpoint = icp['endpoint'] as String?;
if (endpoint == null) {
throw Exception('initiateCall: no endpoint');
}
final id = icp['id'];
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
final external =
(id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? conversationId,
endpoint: endpoint,
callsUserId: callsUserId,
peerExternalId: external,
isVideo: isVideo,
);
}
static String _uuidV4() {
final r = Random();
final b = List<int>.generate(16, (_) => r.nextInt(256));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
final s = List.generate(16, hex).join();
return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
'-${s.substring(16, 20)}-${s.substring(20)}';
}
static String _clientAppKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
final r = Random();
return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join();
}
/// Fetch call history from opcode 79
Future<List<CallLogEntry>> fetchHistory(
int accountId,