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:
@@ -2,6 +2,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
// Backend module for parsing calls from Komet platform
|
// Backend module for parsing calls from Komet platform
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'contacts.dart';
|
import 'contacts.dart';
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
|
||||||
enum CallStatus { missed, canceled, outgoing, incoming }
|
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 {
|
class CallLogEntry {
|
||||||
final String id;
|
final String id;
|
||||||
final int accountId;
|
final int accountId;
|
||||||
@@ -32,6 +57,75 @@ class CallsModule {
|
|||||||
|
|
||||||
CallsModule(this._api);
|
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
|
/// Fetch call history from opcode 79
|
||||||
Future<List<CallLogEntry>> fetchHistory(
|
Future<List<CallLogEntry>> fetchHistory(
|
||||||
int accountId,
|
int accountId,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../backend/api.dart';
|
||||||
|
import '../../backend/modules/calls.dart';
|
||||||
|
import '../protocol/opcode_map.dart';
|
||||||
|
import '../protocol/packet.dart';
|
||||||
|
import 'call_session.dart';
|
||||||
|
import 'conversation_params.dart';
|
||||||
|
import 'ws2_signaling.dart';
|
||||||
|
|
||||||
|
/// Данные входящего звонка (из пуша opcode 137).
|
||||||
|
class IncomingCall {
|
||||||
|
final String conversationId;
|
||||||
|
|
||||||
|
/// ONE_ME id звонящего.
|
||||||
|
final int callerId;
|
||||||
|
final bool isVideo;
|
||||||
|
final ConversationParams params;
|
||||||
|
|
||||||
|
const IncomingCall({
|
||||||
|
required this.conversationId,
|
||||||
|
required this.callerId,
|
||||||
|
required this.isVideo,
|
||||||
|
required this.params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Глобальный оркестратор звонков: слушает входящие (opcode 137),
|
||||||
|
/// инициирует исходящие (opcode 78) и держит активный [CallSession].
|
||||||
|
class CallController {
|
||||||
|
CallController._();
|
||||||
|
static final CallController instance = CallController._();
|
||||||
|
|
||||||
|
Api? _api;
|
||||||
|
CallsModule? _calls;
|
||||||
|
StreamSubscription<Packet>? _pushSub;
|
||||||
|
|
||||||
|
final _incoming = StreamController<IncomingCall>.broadcast();
|
||||||
|
final _ended = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
/// Новый входящий звонок — UI показывает экран/оверлей.
|
||||||
|
Stream<IncomingCall> get incomingCalls => _incoming.stream;
|
||||||
|
|
||||||
|
/// Активный звонок завершился (любой стороной).
|
||||||
|
Stream<void> get callEnded => _ended.stream;
|
||||||
|
|
||||||
|
CallSession? _active;
|
||||||
|
CallSession? get activeSession => _active;
|
||||||
|
|
||||||
|
IncomingCall? _pending;
|
||||||
|
IncomingCall? get pendingIncoming => _pending;
|
||||||
|
|
||||||
|
bool get isBusy => _active != null;
|
||||||
|
|
||||||
|
void init(Api api) {
|
||||||
|
if (_api != null) return;
|
||||||
|
_api = api;
|
||||||
|
_calls = CallsModule(api);
|
||||||
|
_pushSub = api.pushStream.listen(_onPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPush(Packet packet) {
|
||||||
|
if (packet.opcode != Opcode.notifCallStart) return;
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
|
||||||
|
final vcp = payload['vcp'] as String?;
|
||||||
|
final conversationId = payload['conversationId'] as String?;
|
||||||
|
final callerId = payload['callerId'] as int?;
|
||||||
|
if (vcp == null || conversationId == null || callerId == null) return;
|
||||||
|
|
||||||
|
final params = ConversationParams.decode(vcp);
|
||||||
|
if (params == null) return;
|
||||||
|
|
||||||
|
// Уже идёт звонок — новый игнорируем (сервер сам отметит как пропущенный).
|
||||||
|
if (_active != null) return;
|
||||||
|
|
||||||
|
final incoming = IncomingCall(
|
||||||
|
conversationId: conversationId,
|
||||||
|
callerId: callerId,
|
||||||
|
isVideo: payload['type'] == 'VIDEO',
|
||||||
|
params: params,
|
||||||
|
);
|
||||||
|
_pending = incoming;
|
||||||
|
_incoming.add(incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать исходящий 1:1 звонок.
|
||||||
|
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) async {
|
||||||
|
if (_active != null) throw StateError('уже идёт звонок');
|
||||||
|
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
|
||||||
|
final config = Ws2Config.fromEndpoint(out.endpoint, userId: out.callsUserId);
|
||||||
|
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
||||||
|
_bind(session);
|
||||||
|
await session.start();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Принять входящий звонок.
|
||||||
|
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
||||||
|
_pending = null;
|
||||||
|
final config = Ws2Config.fromVcp(
|
||||||
|
call.params,
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
);
|
||||||
|
final session = CallSession(
|
||||||
|
ws2Config: config,
|
||||||
|
params: call.params,
|
||||||
|
role: CallRole.callee,
|
||||||
|
);
|
||||||
|
_bind(session);
|
||||||
|
await session.start();
|
||||||
|
await session.accept();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отклонить входящий звонок (подключаемся к ws2 только чтобы отправить
|
||||||
|
/// `hangup reason=REJECTED`, без медиа).
|
||||||
|
Future<void> rejectIncoming(IncomingCall call) async {
|
||||||
|
_pending = null;
|
||||||
|
final config = Ws2Config.fromVcp(
|
||||||
|
call.params,
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
);
|
||||||
|
final signaling = Ws2Signaling(config);
|
||||||
|
try {
|
||||||
|
await signaling.connect();
|
||||||
|
await signaling.hangup(reason: 'REJECTED');
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
await signaling.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Завершить активный звонок.
|
||||||
|
Future<void> endActive() => _active?.hangup() ?? Future.value();
|
||||||
|
|
||||||
|
/// DEBUG: послать в активный звонок сигнал состояния микрофона
|
||||||
|
/// (`change-media-settings`), не трогая реальный микрофон.
|
||||||
|
/// Возвращает `false`, если активного звонка нет.
|
||||||
|
Future<bool> sendMicSignal(bool enabled) async {
|
||||||
|
final session = _active;
|
||||||
|
if (session == null) return false;
|
||||||
|
await session.sendAudioEnabledSignal(enabled);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bind(CallSession session) {
|
||||||
|
_active = session;
|
||||||
|
session.stateStream.listen((state) {
|
||||||
|
if (state == CallSessionState.ended && _active == session) {
|
||||||
|
_active = null;
|
||||||
|
_ended.add(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_pushSub?.cancel();
|
||||||
|
_incoming.close();
|
||||||
|
_ended.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
|
||||||
|
import 'conversation_params.dart';
|
||||||
|
import 'ws2_signaling.dart';
|
||||||
|
|
||||||
|
enum CallRole { caller, callee }
|
||||||
|
|
||||||
|
enum CallSessionState { connecting, ringing, active, ended }
|
||||||
|
|
||||||
|
/// Один сеанс 1:1 аудиозвонка: связывает сигналинг [Ws2Signaling] с
|
||||||
|
/// `RTCPeerConnection`.
|
||||||
|
///
|
||||||
|
/// Поток (подтверждён захватом `docs/ws2_capture.log` для звонящего и
|
||||||
|
/// реконструирован из `ru.ok.android.externcalls.sdk` для вызываемого):
|
||||||
|
/// - сервер шлёт `connection` → берём ICE-сервера и id собеседника;
|
||||||
|
/// - звонящий: createOffer → `transmit-data`(offer); ждёт `transmitted-data`(answer);
|
||||||
|
/// - вызываемый: `transmitted-data`(offer) → createAnswer → `transmit-data`(answer);
|
||||||
|
/// - обе стороны: ICE-кандидаты через `transmit-data`, приём — через `transmitted-data`;
|
||||||
|
/// - вызываемый по тапу «принять» шлёт `accept-call`.
|
||||||
|
class CallSession {
|
||||||
|
final Ws2Config ws2Config;
|
||||||
|
|
||||||
|
/// Параметры из `vcp` (входящий звонок) — резервный источник ICE-серверов,
|
||||||
|
/// если их нет в пуше `connection`. Для исходящего может быть `null`.
|
||||||
|
final ConversationParams? params;
|
||||||
|
final CallRole role;
|
||||||
|
|
||||||
|
CallSession({
|
||||||
|
required this.ws2Config,
|
||||||
|
required this.role,
|
||||||
|
this.params,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ws2Signaling? _signaling;
|
||||||
|
RTCPeerConnection? _pc;
|
||||||
|
MediaStream? _localStream;
|
||||||
|
|
||||||
|
int? _peerId;
|
||||||
|
String _peerType = 'USER';
|
||||||
|
int _peerDeviceIdx = 0;
|
||||||
|
|
||||||
|
bool _muted = false;
|
||||||
|
bool _accepted = false;
|
||||||
|
|
||||||
|
final _state = StreamController<CallSessionState>.broadcast();
|
||||||
|
final _remoteStream = StreamController<MediaStream>.broadcast();
|
||||||
|
|
||||||
|
Stream<CallSessionState> get stateStream => _state.stream;
|
||||||
|
Stream<MediaStream> get remoteStreamStream => _remoteStream.stream;
|
||||||
|
bool get isMuted => _muted;
|
||||||
|
|
||||||
|
CallSessionState _current = CallSessionState.connecting;
|
||||||
|
DateTime? _activeSince;
|
||||||
|
|
||||||
|
/// Текущее состояние (для переоткрытия свёрнутого экрана —
|
||||||
|
/// broadcast-поток не отдаёт последнее значение новым слушателям).
|
||||||
|
CallSessionState get currentState => _current;
|
||||||
|
|
||||||
|
/// Длительность разговора в секундах (0, пока не активен).
|
||||||
|
int get elapsedSeconds =>
|
||||||
|
_activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds;
|
||||||
|
|
||||||
|
void _setState(CallSessionState s) {
|
||||||
|
if (_current == s || _current == CallSessionState.ended) return;
|
||||||
|
if (s == CallSessionState.active) _activeSince ??= DateTime.now();
|
||||||
|
_current = s;
|
||||||
|
_state.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
_setState(CallSessionState.connecting);
|
||||||
|
final signaling = Ws2Signaling(ws2Config);
|
||||||
|
_signaling = signaling;
|
||||||
|
signaling.notifications.listen(_onNotification, onError: (_) => _end());
|
||||||
|
signaling.done.then((_) => _end());
|
||||||
|
await signaling.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
||||||
|
switch (msg['notification']) {
|
||||||
|
case 'connection':
|
||||||
|
await _onConnection(msg);
|
||||||
|
break;
|
||||||
|
case 'transmitted-data':
|
||||||
|
await _onTransmittedData(msg);
|
||||||
|
break;
|
||||||
|
case 'accepted-call':
|
||||||
|
_setState(CallSessionState.active);
|
||||||
|
break;
|
||||||
|
case 'closed-conversation':
|
||||||
|
_end();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||||
|
final convParams = msg['conversationParams'];
|
||||||
|
final conversation = msg['conversation'];
|
||||||
|
|
||||||
|
final iceServers =
|
||||||
|
_iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
||||||
|
_resolvePeer(conversation);
|
||||||
|
|
||||||
|
final pc = await createPeerConnection({
|
||||||
|
'iceServers': iceServers,
|
||||||
|
'sdpSemantics': 'unified-plan',
|
||||||
|
});
|
||||||
|
_pc = pc;
|
||||||
|
|
||||||
|
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
'audio': true,
|
||||||
|
'video': false,
|
||||||
|
});
|
||||||
|
for (final track in _localStream!.getTracks()) {
|
||||||
|
await pc.addTrack(track, _localStream!);
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.onIceCandidate = _onLocalCandidate;
|
||||||
|
pc.onTrack = (event) {
|
||||||
|
if (event.streams.isNotEmpty) _remoteStream.add(event.streams.first);
|
||||||
|
};
|
||||||
|
pc.onConnectionState = (s) {
|
||||||
|
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||||
|
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||||
|
_end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (role == CallRole.caller) {
|
||||||
|
_setState(CallSessionState.ringing);
|
||||||
|
await _createAndSendOffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createAndSendOffer() async {
|
||||||
|
final pc = _pc;
|
||||||
|
final peerId = _peerId;
|
||||||
|
if (pc == null || peerId == null) return;
|
||||||
|
|
||||||
|
final offer = await pc.createOffer({});
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
await _signaling?.transmitSdp(
|
||||||
|
participantId: peerId,
|
||||||
|
participantType: _peerType,
|
||||||
|
deviceIdx: _peerDeviceIdx,
|
||||||
|
type: offer.type!,
|
||||||
|
sdp: offer.sdp!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
||||||
|
final pc = _pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
|
||||||
|
final data = msg['data'];
|
||||||
|
if (data is! Map) return;
|
||||||
|
|
||||||
|
final sdp = data['sdp'];
|
||||||
|
if (sdp is Map) {
|
||||||
|
final type = sdp['type'] as String?;
|
||||||
|
final desc = sdp['sdp'] as String?;
|
||||||
|
if (type == null || desc == null) return;
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(RTCSessionDescription(desc, type));
|
||||||
|
|
||||||
|
if (type == 'offer') {
|
||||||
|
// Сторона вызываемого: отвечаем answer.
|
||||||
|
final answer = await pc.createAnswer({});
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
final peerId = _peerId;
|
||||||
|
if (peerId != null) {
|
||||||
|
await _signaling?.transmitSdp(
|
||||||
|
participantId: peerId,
|
||||||
|
participantType: _peerType,
|
||||||
|
deviceIdx: _peerDeviceIdx,
|
||||||
|
type: answer.type!,
|
||||||
|
sdp: answer.sdp!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_current == CallSessionState.connecting) {
|
||||||
|
_setState(CallSessionState.ringing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidate = data['candidate'];
|
||||||
|
if (candidate is Map) {
|
||||||
|
await pc.addCandidate(RTCIceCandidate(
|
||||||
|
candidate['candidate'] as String?,
|
||||||
|
candidate['sdpMid'] as String?,
|
||||||
|
candidate['sdpMLineIndex'] as int?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLocalCandidate(RTCIceCandidate candidate) {
|
||||||
|
final peerId = _peerId;
|
||||||
|
if (peerId == null || candidate.candidate == null) return;
|
||||||
|
_signaling?.transmitCandidate(
|
||||||
|
participantId: peerId,
|
||||||
|
participantType: _peerType,
|
||||||
|
deviceIdx: _peerDeviceIdx,
|
||||||
|
candidate: candidate.candidate!,
|
||||||
|
sdpMid: candidate.sdpMid ?? '0',
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Принять входящий звонок (сторона вызываемого).
|
||||||
|
Future<void> accept() async {
|
||||||
|
if (_accepted) return;
|
||||||
|
_accepted = true;
|
||||||
|
await _signaling?.acceptCall();
|
||||||
|
await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
|
||||||
|
_setState(CallSessionState.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DEBUG: отправить серверу сигнал `change-media-settings` с заданным
|
||||||
|
/// состоянием микрофона, НЕ трогая реальный аудиотрек.
|
||||||
|
Future<void> sendAudioEnabledSignal(bool enabled) async {
|
||||||
|
await _signaling?.changeMediaSettings(isAudioEnabled: enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMuted(bool muted) async {
|
||||||
|
_muted = muted;
|
||||||
|
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||||
|
track.enabled = !muted;
|
||||||
|
}
|
||||||
|
await _signaling?.changeMediaSettings(isAudioEnabled: !muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> hangup({String reason = 'HUNGUP'}) async {
|
||||||
|
try {
|
||||||
|
await _signaling?.hangup(reason: reason);
|
||||||
|
} catch (_) {}
|
||||||
|
_end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _ended = false;
|
||||||
|
void _end() {
|
||||||
|
if (_ended) return;
|
||||||
|
_ended = true;
|
||||||
|
_setState(CallSessionState.ended);
|
||||||
|
_dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dispose() async {
|
||||||
|
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||||
|
await track.stop();
|
||||||
|
}
|
||||||
|
await _localStream?.dispose();
|
||||||
|
await _pc?.close();
|
||||||
|
await _signaling?.close();
|
||||||
|
if (!_state.isClosed) await _state.close();
|
||||||
|
if (!_remoteStream.isClosed) await _remoteStream.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resolvePeer(Object? conversation) {
|
||||||
|
if (conversation is! Map) return;
|
||||||
|
final participants = conversation['participants'];
|
||||||
|
if (participants is! List) return;
|
||||||
|
for (final p in participants.whereType<Map>()) {
|
||||||
|
final id = p['id'];
|
||||||
|
if (id is int && id != ws2Config.userId) {
|
||||||
|
_peerId = id;
|
||||||
|
final responderTypes = p['responderTypes'];
|
||||||
|
if (responderTypes is List && responderTypes.isNotEmpty) {
|
||||||
|
_peerType = responderTypes.first.toString();
|
||||||
|
}
|
||||||
|
final deviceIdxs = p['responderDeviceIdxs'];
|
||||||
|
if (deviceIdxs is List && deviceIdxs.isNotEmpty && deviceIdxs.first is int) {
|
||||||
|
_peerDeviceIdx = deviceIdxs.first as int;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>>? _iceServersFrom(Object? convParams) {
|
||||||
|
if (convParams is! Map) return null;
|
||||||
|
final servers = <Map<String, dynamic>>[];
|
||||||
|
final stun = convParams['stun'];
|
||||||
|
if (stun is Map && stun['urls'] != null) {
|
||||||
|
servers.add({'urls': stun['urls']});
|
||||||
|
}
|
||||||
|
final turn = convParams['turn'];
|
||||||
|
if (turn is Map && turn['urls'] != null) {
|
||||||
|
servers.add({
|
||||||
|
'urls': turn['urls'],
|
||||||
|
if (turn['username'] != null) 'username': turn['username'],
|
||||||
|
if (turn['credential'] != null) 'credential': turn['credential'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return servers.isEmpty ? null : servers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../protocol/lz4_block.dart';
|
||||||
|
|
||||||
|
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
|
||||||
|
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
|
||||||
|
///
|
||||||
|
/// Формат строки: `<rawLen>:<base64(LZ4-block)>`. После распаковки —
|
||||||
|
/// компактный JSON с короткими ключами. Расшифровка повторяет
|
||||||
|
/// `ru.ok.android.externcalls.sdk.api.ConversationParams.decode`.
|
||||||
|
class ConversationParams {
|
||||||
|
/// Токен авторизации в сигналинге звонка.
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
/// WebSocket сигналинга, напр. `wss://videowebrtc.okcdn.ru/ws2`.
|
||||||
|
final String wsEndpoint;
|
||||||
|
final List<String> wsIps;
|
||||||
|
|
||||||
|
/// HTTP/3 web-transport fallback, напр. `https://videowebrtc.okcdn.ru:23456/wt`.
|
||||||
|
final String? wtEndpoint;
|
||||||
|
final List<String> wtIps;
|
||||||
|
|
||||||
|
/// API звонков, напр. `https://calls.okcdn.ru`.
|
||||||
|
final String? callsApiEndpoint;
|
||||||
|
final List<String> callsApiIps;
|
||||||
|
|
||||||
|
/// Тип клиента, напр. `one_me`.
|
||||||
|
final String? clientType;
|
||||||
|
|
||||||
|
/// Время истечения параметров (unix-секунды).
|
||||||
|
final int? expiresAt;
|
||||||
|
|
||||||
|
final String? stun;
|
||||||
|
final List<String> turn;
|
||||||
|
final String? turnUser;
|
||||||
|
final String? turnPassword;
|
||||||
|
|
||||||
|
final bool isVideo;
|
||||||
|
|
||||||
|
const ConversationParams({
|
||||||
|
required this.token,
|
||||||
|
required this.wsEndpoint,
|
||||||
|
this.wsIps = const [],
|
||||||
|
this.wtEndpoint,
|
||||||
|
this.wtIps = const [],
|
||||||
|
this.callsApiEndpoint,
|
||||||
|
this.callsApiIps = const [],
|
||||||
|
this.clientType,
|
||||||
|
this.expiresAt,
|
||||||
|
this.stun,
|
||||||
|
this.turn = const [],
|
||||||
|
this.turnUser,
|
||||||
|
this.turnPassword,
|
||||||
|
this.isVideo = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ICE-серверы в формате, который ожидает `flutter_webrtc`
|
||||||
|
/// (`RTCPeerConnection`).
|
||||||
|
List<Map<String, dynamic>> get iceServers {
|
||||||
|
final servers = <Map<String, dynamic>>[];
|
||||||
|
if (stun != null && stun!.isNotEmpty) {
|
||||||
|
servers.add({'urls': stun});
|
||||||
|
}
|
||||||
|
if (turn.isNotEmpty) {
|
||||||
|
servers.add({
|
||||||
|
'urls': turn,
|
||||||
|
if (turnUser != null) 'username': turnUser,
|
||||||
|
if (turnPassword != null) 'credential': turnPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true`, если параметры ещё действительны (с запасом в 5 секунд).
|
||||||
|
bool get isExpired {
|
||||||
|
if (expiresAt == null) return false;
|
||||||
|
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
return nowSec >= expiresAt! - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _splitTurn(Object? value) {
|
||||||
|
if (value is! String || value.isEmpty) return const [];
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _stringList(Object? value) {
|
||||||
|
if (value is! List) return const [];
|
||||||
|
return value.whereType<String>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Распаковывает и парсит строку `vcp`. Возвращает `null`, если формат
|
||||||
|
/// не распознан.
|
||||||
|
static ConversationParams? decode(String vcp) {
|
||||||
|
final sep = vcp.indexOf(':');
|
||||||
|
if (sep <= 0) return null;
|
||||||
|
|
||||||
|
final rawLen = int.tryParse(vcp.substring(0, sep));
|
||||||
|
if (rawLen == null || rawLen <= 0) return null;
|
||||||
|
|
||||||
|
final Uint8List compressed;
|
||||||
|
try {
|
||||||
|
compressed = base64.decode(vcp.substring(sep + 1));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
try {
|
||||||
|
final decompressed = lz4BlockDecompress(compressed, rawLen);
|
||||||
|
bytes = decompressed.length > rawLen
|
||||||
|
? Uint8List.sublistView(decompressed, 0, rawLen)
|
||||||
|
: decompressed;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? json;
|
||||||
|
try {
|
||||||
|
json = jsonDecode(utf8.decode(bytes));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (json is! Map) return null;
|
||||||
|
|
||||||
|
final token = json['tkn'];
|
||||||
|
final wse = json['wse'];
|
||||||
|
if (token is! String || wse is! String) return null;
|
||||||
|
|
||||||
|
return ConversationParams(
|
||||||
|
token: token,
|
||||||
|
wsEndpoint: wse,
|
||||||
|
wsIps: _stringList(json['wsip']),
|
||||||
|
wtEndpoint: json['wte'] as String?,
|
||||||
|
wtIps: _stringList(json['wtip']),
|
||||||
|
callsApiEndpoint: json['vcae'] as String?,
|
||||||
|
callsApiIps: _stringList(json['vcaip']),
|
||||||
|
clientType: json['srcp'] as String?,
|
||||||
|
expiresAt: json['et'] is int ? json['et'] as int : null,
|
||||||
|
stun: json['stne'] as String?,
|
||||||
|
turn: _splitTurn(json['trne']),
|
||||||
|
turnUser: json['trnu'] as String?,
|
||||||
|
turnPassword: json['trnp'] as String?,
|
||||||
|
isVideo: json['iv'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||||
|
///
|
||||||
|
/// Сервер шлёт block-формат как в транспорте (payload пакетов), так и в
|
||||||
|
/// `vcp`-параметрах звонка. dart_lz4 поддерживает только frame-формат, поэтому
|
||||||
|
/// block распаковывается вручную.
|
||||||
|
Uint8List lz4BlockDecompress(Uint8List src, int maxSize) {
|
||||||
|
var out = Uint8List(1024);
|
||||||
|
int outLen = 0;
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
void ensure(int extra) {
|
||||||
|
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
|
||||||
|
if (outLen + extra <= out.length) return;
|
||||||
|
var newCap = out.length * 2;
|
||||||
|
while (newCap < outLen + extra) {
|
||||||
|
newCap *= 2;
|
||||||
|
}
|
||||||
|
if (newCap > maxSize) newCap = maxSize;
|
||||||
|
final grown = Uint8List(newCap);
|
||||||
|
grown.setRange(0, outLen, out);
|
||||||
|
out = grown;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pos < src.length) {
|
||||||
|
final token = src[pos++];
|
||||||
|
var litLen = token >> 4;
|
||||||
|
|
||||||
|
if (litLen == 15) {
|
||||||
|
while (pos < src.length) {
|
||||||
|
final b = src[pos++];
|
||||||
|
litLen += b;
|
||||||
|
if (b != 255) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (litLen > 0) {
|
||||||
|
ensure(litLen);
|
||||||
|
out.setRange(outLen, outLen + litLen, src, pos);
|
||||||
|
outLen += litLen;
|
||||||
|
pos += litLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= src.length) break;
|
||||||
|
|
||||||
|
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
|
||||||
|
final offset = src[pos] | (src[pos + 1] << 8);
|
||||||
|
pos += 2;
|
||||||
|
if (offset == 0) throw StateError('LZ4: offset = 0');
|
||||||
|
|
||||||
|
var matchLen = (token & 0x0F) + 4;
|
||||||
|
if ((token & 0x0F) == 0x0F) {
|
||||||
|
while (pos < src.length) {
|
||||||
|
final b = src[pos++];
|
||||||
|
matchLen += b;
|
||||||
|
if (b != 255) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure(matchLen);
|
||||||
|
final start = outLen - offset;
|
||||||
|
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
|
||||||
|
for (var i = 0; i < matchLen; i++) {
|
||||||
|
out[outLen + i] = out[start + i];
|
||||||
|
}
|
||||||
|
outLen += matchLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8List.sublistView(out, 0, outLen);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:isolate';
|
|||||||
import 'package:dart_lz4/dart_lz4.dart';
|
import 'package:dart_lz4/dart_lz4.dart';
|
||||||
import 'package:libcompress/libcompress.dart';
|
import 'package:libcompress/libcompress.dart';
|
||||||
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
||||||
|
import 'lz4_block.dart';
|
||||||
|
|
||||||
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
|
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
|
||||||
const int headerSize = 10;
|
const int headerSize = 10;
|
||||||
@@ -203,75 +204,9 @@ Uint8List _decompressPayload(Uint8List src) {
|
|||||||
|
|
||||||
// По умолчанию — LZ4 block (без magic)
|
// По умолчанию — LZ4 block (без magic)
|
||||||
try {
|
try {
|
||||||
return _lz4BlockDecompress(src, _maxDecompressedSize);
|
return lz4BlockDecompress(src, _maxDecompressedSize);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('LZ4 block decompression error: $e');
|
throw Exception('LZ4 block decompression error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
|
||||||
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
|
|
||||||
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
|
|
||||||
var out = Uint8List(1024);
|
|
||||||
int outLen = 0;
|
|
||||||
int pos = 0;
|
|
||||||
|
|
||||||
void ensure(int extra) {
|
|
||||||
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
|
|
||||||
if (outLen + extra <= out.length) return;
|
|
||||||
var newCap = out.length * 2;
|
|
||||||
while (newCap < outLen + extra) {
|
|
||||||
newCap *= 2;
|
|
||||||
}
|
|
||||||
if (newCap > maxSize) newCap = maxSize;
|
|
||||||
final grown = Uint8List(newCap);
|
|
||||||
grown.setRange(0, outLen, out);
|
|
||||||
out = grown;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (pos < src.length) {
|
|
||||||
final token = src[pos++];
|
|
||||||
var litLen = token >> 4;
|
|
||||||
|
|
||||||
if (litLen == 15) {
|
|
||||||
while (pos < src.length) {
|
|
||||||
final b = src[pos++];
|
|
||||||
litLen += b;
|
|
||||||
if (b != 255) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (litLen > 0) {
|
|
||||||
ensure(litLen);
|
|
||||||
out.setRange(outLen, outLen + litLen, src, pos);
|
|
||||||
outLen += litLen;
|
|
||||||
pos += litLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos >= src.length) break;
|
|
||||||
|
|
||||||
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
|
|
||||||
final offset = src[pos] | (src[pos + 1] << 8);
|
|
||||||
pos += 2;
|
|
||||||
if (offset == 0) throw StateError('LZ4: offset = 0');
|
|
||||||
|
|
||||||
var matchLen = (token & 0x0F) + 4;
|
|
||||||
if ((token & 0x0F) == 0x0F) {
|
|
||||||
while (pos < src.length) {
|
|
||||||
final b = src[pos++];
|
|
||||||
matchLen += b;
|
|
||||||
if (b != 255) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure(matchLen);
|
|
||||||
final start = outLen - offset;
|
|
||||||
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
|
|
||||||
for (var i = 0; i < matchLen; i++) {
|
|
||||||
out[outLen + i] = out[start + i];
|
|
||||||
}
|
|
||||||
outLen += matchLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uint8List.sublistView(out, 0, outLen);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,22 +2,31 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' show Helper;
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
import '../../../core/calls/call_controller.dart';
|
||||||
|
import '../../../core/calls/call_session.dart';
|
||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
|
|
||||||
enum CallScreenState { incoming, outgoing, active }
|
/// Экран звонка. Управляется живым [CallSession].
|
||||||
|
///
|
||||||
|
/// Открывается в одном из режимов:
|
||||||
|
/// - исходящий/активный: передан [session] (уже запущен);
|
||||||
|
/// - входящий: передан [incoming] — показываем «принять/отклонить», сессия
|
||||||
|
/// создаётся при принятии.
|
||||||
class CallScreen extends StatefulWidget {
|
class CallScreen extends StatefulWidget {
|
||||||
final String name;
|
final String name;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final CallScreenState initialState;
|
final CallSession? session;
|
||||||
|
final IncomingCall? incoming;
|
||||||
|
|
||||||
const CallScreen({
|
const CallScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.initialState = CallScreenState.incoming,
|
this.session,
|
||||||
|
this.incoming,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -26,18 +35,21 @@ class CallScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _CallScreenState extends State<CallScreen>
|
class _CallScreenState extends State<CallScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late CallScreenState _state;
|
CallSession? _session;
|
||||||
|
StreamSubscription<CallSessionState>? _stateSub;
|
||||||
|
CallSessionState _state = CallSessionState.connecting;
|
||||||
|
bool _incomingPending = false;
|
||||||
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _seconds = 0;
|
|
||||||
bool _isMuted = false;
|
bool _isMuted = false;
|
||||||
bool _isSpeaker = false;
|
bool _isSpeaker = false;
|
||||||
|
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late Animation<double> _pulseAnimation;
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_state = widget.initialState;
|
|
||||||
_pulseController = AnimationController(
|
_pulseController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1500),
|
duration: const Duration(milliseconds: 1500),
|
||||||
@@ -45,46 +57,88 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||||
);
|
);
|
||||||
if (_state == CallScreenState.outgoing) {
|
|
||||||
_startOutgoingTimer();
|
_incomingPending = widget.session == null && widget.incoming != null;
|
||||||
|
if (widget.session != null) _bind(widget.session!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bind(CallSession session) {
|
||||||
|
_session = session;
|
||||||
|
_state = session.currentState;
|
||||||
|
_stateSub = session.stateStream.listen(_onState);
|
||||||
|
if (_state == CallSessionState.active) _startActiveTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onState(CallSessionState state) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _state = state);
|
||||||
|
if (state == CallSessionState.active) {
|
||||||
|
_startActiveTimer();
|
||||||
|
} else if (state == CallSessionState.ended) {
|
||||||
|
_close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startOutgoingTimer() {
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _seconds++);
|
|
||||||
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
|
|
||||||
_timer?.cancel();
|
|
||||||
setState(() => _state = CallScreenState.active);
|
|
||||||
_startActiveTimer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startActiveTimer() {
|
void _startActiveTimer() {
|
||||||
_seconds = 0;
|
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _seconds++);
|
setState(() {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _accept() {
|
Future<void> _accept() async {
|
||||||
|
final incoming = widget.incoming;
|
||||||
|
if (incoming == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = CallScreenState.active;
|
_incomingPending = false;
|
||||||
_seconds = 0;
|
_state = CallSessionState.connecting;
|
||||||
});
|
});
|
||||||
_startActiveTimer();
|
try {
|
||||||
|
final session = await CallController.instance.acceptIncoming(incoming);
|
||||||
|
if (!mounted) return;
|
||||||
|
_bind(session);
|
||||||
|
} catch (_) {
|
||||||
|
_close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endCall() {
|
Future<void> _decline() async {
|
||||||
|
final incoming = widget.incoming;
|
||||||
|
if (incoming != null) {
|
||||||
|
await CallController.instance.rejectIncoming(incoming);
|
||||||
|
}
|
||||||
|
_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hangup() async {
|
||||||
|
final session = _session;
|
||||||
|
if (session != null) {
|
||||||
|
await session.hangup();
|
||||||
|
}
|
||||||
|
_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() {
|
||||||
|
if (!mounted) return;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
Navigator.pop(context);
|
Navigator.of(context).maybePop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleMute() async {
|
||||||
|
final next = !_isMuted;
|
||||||
|
setState(() => _isMuted = next);
|
||||||
|
await _session?.setMuted(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleSpeaker() async {
|
||||||
|
final next = !_isSpeaker;
|
||||||
|
setState(() => _isSpeaker = next);
|
||||||
|
await Helper.setSpeakerphoneOn(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stateSub?.cancel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_pulseController.dispose();
|
_pulseController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -97,33 +151,55 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF0E0E14),
|
backgroundColor: const Color(0xFF0E0E14),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 3),
|
Column(
|
||||||
_buildAvatar(screenH),
|
children: [
|
||||||
const SizedBox(height: 24),
|
const Spacer(flex: 3),
|
||||||
_buildName(),
|
_buildAvatar(screenH),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 24),
|
||||||
_buildStatus(),
|
_buildName(),
|
||||||
const Spacer(flex: 2),
|
const SizedBox(height: 8),
|
||||||
_buildActions(),
|
_buildStatus(),
|
||||||
const SizedBox(height: 48),
|
const Spacer(flex: 2),
|
||||||
|
_buildActions(),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Symbols.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
tooltip: 'Свернуть',
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _isRinging =>
|
||||||
|
_incomingPending ||
|
||||||
|
_state == CallSessionState.connecting ||
|
||||||
|
_state == CallSessionState.ringing;
|
||||||
|
|
||||||
Widget _buildAvatar(double screenH) {
|
Widget _buildAvatar(double screenH) {
|
||||||
final size = screenH * 0.18;
|
final size = screenH * 0.18;
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final isRinging = _state == CallScreenState.incoming;
|
|
||||||
final isOutgoing = _state == CallScreenState.outgoing;
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _pulseAnimation,
|
animation: _pulseAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0;
|
final scale = _isRinging ? _pulseAnimation.value : 1.0;
|
||||||
return Transform.scale(scale: scale, child: child);
|
return Transform.scale(scale: scale, child: child);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -189,13 +265,22 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
Widget _buildStatus() {
|
Widget _buildStatus() {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
String text;
|
String text;
|
||||||
switch (_state) {
|
if (_incomingPending) {
|
||||||
case CallScreenState.incoming:
|
text = 'Входящий звонок';
|
||||||
text = 'Входящий звонок';
|
} else {
|
||||||
case CallScreenState.outgoing:
|
switch (_state) {
|
||||||
text = 'Вызов...';
|
case CallSessionState.connecting:
|
||||||
case CallScreenState.active:
|
text = 'Соединение…';
|
||||||
text = formatSecondsMmSs(_seconds, padMinutes: true);
|
case CallSessionState.ringing:
|
||||||
|
text = 'Вызов…';
|
||||||
|
case CallSessionState.active:
|
||||||
|
text = formatSecondsMmSs(
|
||||||
|
_session?.elapsedSeconds ?? 0,
|
||||||
|
padMinutes: true,
|
||||||
|
);
|
||||||
|
case CallSessionState.ended:
|
||||||
|
text = 'Звонок завершён';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
text,
|
text,
|
||||||
@@ -208,14 +293,9 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActions() {
|
Widget _buildActions() {
|
||||||
switch (_state) {
|
if (_incomingPending) return _buildIncomingActions();
|
||||||
case CallScreenState.incoming:
|
if (_state == CallSessionState.active) return _buildActiveActions();
|
||||||
return _buildIncomingActions();
|
return _buildOutgoingActions();
|
||||||
case CallScreenState.outgoing:
|
|
||||||
return _buildOutgoingActions();
|
|
||||||
case CallScreenState.active:
|
|
||||||
return _buildActiveActions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIncomingActions() {
|
Widget _buildIncomingActions() {
|
||||||
@@ -226,7 +306,7 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
icon: Symbols.phone_disabled,
|
icon: Symbols.phone_disabled,
|
||||||
label: 'Отклонить',
|
label: 'Отклонить',
|
||||||
color: const Color(0xFFBA1A1A),
|
color: const Color(0xFFBA1A1A),
|
||||||
onTap: _endCall,
|
onTap: _decline,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 48),
|
const SizedBox(width: 48),
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
@@ -247,7 +327,7 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
icon: Symbols.phone_disabled,
|
icon: Symbols.phone_disabled,
|
||||||
label: 'Отмена',
|
label: 'Отмена',
|
||||||
color: const Color(0xFFBA1A1A),
|
color: const Color(0xFFBA1A1A),
|
||||||
onTap: _endCall,
|
onTap: _hangup,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -262,13 +342,13 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
_CircleActionButton(
|
_CircleActionButton(
|
||||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||||
active: _isMuted,
|
active: _isMuted,
|
||||||
onTap: () => setState(() => _isMuted = !_isMuted),
|
onTap: _toggleMute,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 32),
|
const SizedBox(width: 32),
|
||||||
_CircleActionButton(
|
_CircleActionButton(
|
||||||
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up,
|
icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down,
|
||||||
active: _isSpeaker,
|
active: _isSpeaker,
|
||||||
onTap: () => setState(() => _isSpeaker = !_isSpeaker),
|
onTap: _toggleSpeaker,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 32),
|
const SizedBox(width: 32),
|
||||||
_CircleActionButton(
|
_CircleActionButton(
|
||||||
@@ -283,7 +363,7 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
icon: Symbols.phone_disabled,
|
icon: Symbols.phone_disabled,
|
||||||
label: 'Завершить',
|
label: 'Завершить',
|
||||||
color: const Color(0xFFBA1A1A),
|
color: const Color(0xFFBA1A1A),
|
||||||
onTap: _endCall,
|
onTap: _hangup,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import 'package:komet/frontend/widgets/custom_notification.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
|
import '../../../core/calls/call_controller.dart';
|
||||||
|
import '../calls/call_screen.dart';
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/protocol/opcode_map.dart';
|
||||||
import '../../../core/protocol/packet.dart';
|
import '../../../core/protocol/packet.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
@@ -656,6 +658,46 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _startCall() async {
|
||||||
|
if (widget.chatType != 'DIALOG') {
|
||||||
|
showCustomNotification(context, 'Звонки доступны только в диалогах');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
|
||||||
|
final active = CallController.instance.activeSession;
|
||||||
|
if (active != null) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallScreen(
|
||||||
|
name: widget.name,
|
||||||
|
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||||||
|
session: active,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final peerId = widget.chatId ^ _myId;
|
||||||
|
if (peerId <= 0) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
try {
|
||||||
|
final session = await CallController.instance.startOutgoing(peerId);
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallScreen(
|
||||||
|
name: widget.name,
|
||||||
|
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
|
||||||
|
session: session,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Не удалось начать звонок');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _recomputeHeaderStatus() {
|
void _recomputeHeaderStatus() {
|
||||||
_headerStatusNotifier.value = _headerStatus();
|
_headerStatusNotifier.value = _headerStatus();
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1236,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.call, weight: 400),
|
icon: const Icon(Symbols.call, weight: 400),
|
||||||
onPressed: () {},
|
onPressed: _startCall,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.more_vert, weight: 400),
|
icon: const Icon(Symbols.more_vert, weight: 400),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../../widgets/custom_notification.dart';
|
|||||||
import '../../widgets/sheet_helpers.dart';
|
import '../../widgets/sheet_helpers.dart';
|
||||||
import '../../widgets/login_success_screen.dart';
|
import '../../widgets/login_success_screen.dart';
|
||||||
import '../calls/call_screen.dart';
|
import '../calls/call_screen.dart';
|
||||||
|
import '../../../core/calls/call_controller.dart';
|
||||||
import '../digital_id/digital_id_web_screen.dart';
|
import '../digital_id/digital_id_web_screen.dart';
|
||||||
|
|
||||||
class DebugMenuScreen extends StatefulWidget {
|
class DebugMenuScreen extends StatefulWidget {
|
||||||
@@ -36,6 +37,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
final Map<String, String> _errors = {};
|
final Map<String, String> _errors = {};
|
||||||
int _cacheSize = 0;
|
int _cacheSize = 0;
|
||||||
bool _clearingCache = false;
|
bool _clearingCache = false;
|
||||||
|
bool _micSignalOn = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -43,6 +45,18 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
_loadCacheSize();
|
_loadCacheSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMicSignal(bool enabled) async {
|
||||||
|
setState(() => _micSignalOn = enabled);
|
||||||
|
final sent = await CallController.instance.sendMicSignal(enabled);
|
||||||
|
if (!mounted) return;
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
sent
|
||||||
|
? 'Сигнал микрофона: ${enabled ? 'ВКЛ' : 'ВЫКЛ'} отправлен'
|
||||||
|
: 'Нет активного звонка',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCacheSize() async {
|
Future<void> _loadCacheSize() async {
|
||||||
final size = await MediaCache.currentSize();
|
final size = await MediaCache.currentSize();
|
||||||
if (mounted) setState(() => _cacheSize = size);
|
if (mounted) setState(() => _cacheSize = size);
|
||||||
@@ -956,54 +970,46 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_DebugCallButton(
|
||||||
|
label: 'Экран звонка (превью)',
|
||||||
|
icon: Symbols.phone,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const CallScreen(name: 'Кирил Г.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _DebugCallButton(
|
child: Column(
|
||||||
label: 'Входящий',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
icon: Symbols.call_received,
|
children: [
|
||||||
onTap: () => Navigator.push(
|
Text(
|
||||||
context,
|
'Сигнал микрофона (тест)',
|
||||||
MaterialPageRoute(
|
style: TextStyle(
|
||||||
builder: (_) => const CallScreen(
|
color: cs.onSurface,
|
||||||
name: 'Кирил Г.',
|
fontSize: 16,
|
||||||
initialState: CallScreenState.incoming,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Шлёт change-media-settings в активный звонок, '
|
||||||
|
'не меняя реальный микрофон',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
Switch(
|
||||||
Expanded(
|
value: _micSignalOn,
|
||||||
child: _DebugCallButton(
|
onChanged: _sendMicSignal,
|
||||||
label: 'Исходящий',
|
|
||||||
icon: Symbols.call_made,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const CallScreen(
|
|
||||||
name: 'Кирил Г.',
|
|
||||||
initialState: CallScreenState.outgoing,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _DebugCallButton(
|
|
||||||
label: 'Активный',
|
|
||||||
icon: Symbols.phone_in_talk,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const CallScreen(
|
|
||||||
name: 'Кирил Г.',
|
|
||||||
initialState: CallScreenState.active,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+44
-3
@@ -33,6 +33,8 @@ import 'backend/modules/messages.dart';
|
|||||||
import 'backend/modules/polls.dart';
|
import 'backend/modules/polls.dart';
|
||||||
import 'backend/modules/webapp.dart';
|
import 'backend/modules/webapp.dart';
|
||||||
import 'backend/modules/digital_id.dart';
|
import 'backend/modules/digital_id.dart';
|
||||||
|
import 'core/calls/call_controller.dart';
|
||||||
|
import 'frontend/screens/calls/call_screen.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
import 'core/transport/tls_config.dart';
|
import 'core/transport/tls_config.dart';
|
||||||
@@ -189,6 +191,7 @@ class KometAppState extends State<KometApp>
|
|||||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||||
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
||||||
|
StreamSubscription<IncomingCall>? _callIncomingSub;
|
||||||
Timer? _scheduleTimer;
|
Timer? _scheduleTimer;
|
||||||
String? _lastVpnNotice;
|
String? _lastVpnNotice;
|
||||||
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
@@ -233,12 +236,18 @@ class KometAppState extends State<KometApp>
|
|||||||
});
|
});
|
||||||
|
|
||||||
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
||||||
if (status == LoginStatus.success && isOnemeFlavor) {
|
if (status == LoginStatus.success) {
|
||||||
await PushService.instance.init(api: api, account: accountModule);
|
CallController.instance.init(api);
|
||||||
await PushService.instance.onLoginSuccess();
|
if (isOnemeFlavor) {
|
||||||
|
await PushService.instance.init(api: api, account: accountModule);
|
||||||
|
await PushService.instance.onLoginSuccess();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_callIncomingSub =
|
||||||
|
CallController.instance.incomingCalls.listen(_onIncomingCall);
|
||||||
|
|
||||||
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
|
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
|
||||||
if (_isLoggingOut) return;
|
if (_isLoggingOut) return;
|
||||||
_isLoggingOut = true;
|
_isLoggingOut = true;
|
||||||
@@ -287,12 +296,44 @@ class KometAppState extends State<KometApp>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onIncomingCall(IncomingCall call) async {
|
||||||
|
String name = 'Входящий звонок';
|
||||||
|
String? avatar;
|
||||||
|
try {
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
if (profile != null) {
|
||||||
|
final contacts = await ContactsModule.getContacts(profile.id);
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (c.id == call.callerId) {
|
||||||
|
final full = '${c.firstName} ${c.lastName ?? ''}'.trim();
|
||||||
|
if (full.isNotEmpty) name = full;
|
||||||
|
avatar = c.baseUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final navState = KometApp.navigatorKey.currentState;
|
||||||
|
if (navState == null) return;
|
||||||
|
navState.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallScreen(
|
||||||
|
name: name,
|
||||||
|
avatarUrl: avatar,
|
||||||
|
incoming: call,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_finishReveal();
|
_finishReveal();
|
||||||
_sessionExpiredSub?.cancel();
|
_sessionExpiredSub?.cancel();
|
||||||
_loginStatusSub?.cancel();
|
_loginStatusSub?.cancel();
|
||||||
_vpnBypassSub?.cancel();
|
_vpnBypassSub?.cancel();
|
||||||
|
_callIncomingSub?.cancel();
|
||||||
_scheduleTimer?.cancel();
|
_scheduleTimer?.cancel();
|
||||||
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
|
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
|
||||||
AppAmoled.current.removeListener(_onAmoledChanged);
|
AppAmoled.current.removeListener(_onAmoledChanged);
|
||||||
|
|||||||
+28
-4
@@ -169,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
dart_webrtc:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_webrtc
|
||||||
|
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.8.1"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -517,6 +525,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_webrtc:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_webrtc
|
||||||
|
sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.12+hotfix.1"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -721,10 +737,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1110,10 +1126,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1210,6 +1226,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webrtc_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webrtc_interface
|
||||||
|
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ dependencies:
|
|||||||
firebase_messaging: ^16.0.2
|
firebase_messaging: ^16.0.2
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
flutter_webrtc: ^0.12.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/core/calls/conversation_params.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Реальный `vcp` из захвата входящего звонка (docs/PCAPdroid_10_июн._15_32_04).
|
||||||
|
const sampleVcp =
|
||||||
|
'532:8Ux7InRrbiI6IjZ5OHFHbkx4czJ0TXk5d1dOZjZFVms2OEN6QlR3Vmg3OGxBaDZZem4zems9Iiwid3NlIjoid3NzOi8vdmlkZW93ZWJydGMub2tjZG4ucnUvd3My'
|
||||||
|
'JwD6B2lwIjpbIjE1NS4yMTIuMjA0LjExIiwRAIA5NiJdLCJ3dFMAT2h0dHBVAAWQOjIzNDU2L3d0gQAfdFoAFzh2Y2FbAFVjYWxsc6oAESIgAA9NAAAsOTWoAADSAAnKABEzuQDwFHNyY3AiOiJvbmVfbWUi'
|
||||||
|
'LCJldCI6MTc4MTA5NDc1Mywic3RufwBWc3R1bjo/AJA1LjgyOjE5MzBWACF0ciMAP3R1ciMAAx0sGgAoMTQ9AEB1IjoicwDyBDEyMzM3Mzo5MTAyMTUzNDUyOTdeAAChAPAMOVhWbTduUnoxMEFuVkZWN2t0M003aGdDL3hR0wGgaXYiOmZhbHNlfQ==';
|
||||||
|
|
||||||
|
test('decodes the captured vcp blob', () {
|
||||||
|
final params = ConversationParams.decode(sampleVcp);
|
||||||
|
|
||||||
|
expect(params, isNotNull);
|
||||||
|
expect(params!.token, '6y8qGnLxs2tMy9wWNf6EVk68CzBTwVh78lAh6Yzn3zk=');
|
||||||
|
expect(params.wsEndpoint, 'wss://videowebrtc.okcdn.ru/ws2');
|
||||||
|
expect(params.wtEndpoint, 'https://videowebrtc.okcdn.ru:23456/wt');
|
||||||
|
expect(params.callsApiEndpoint, 'https://calls.okcdn.ru');
|
||||||
|
expect(params.clientType, 'one_me');
|
||||||
|
expect(params.expiresAt, 1781094753);
|
||||||
|
expect(params.stun, 'stun:155.212.205.82:19302');
|
||||||
|
expect(params.turn, [
|
||||||
|
'turn:155.212.205.82:19302',
|
||||||
|
'turn:155.212.205.14:19302',
|
||||||
|
]);
|
||||||
|
expect(params.turnUser, '1781123373:910215345297');
|
||||||
|
expect(params.turnPassword, '9XVm7nRz10AnVFV7kt3M7hgC/xQ=');
|
||||||
|
expect(params.isVideo, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds ice servers for flutter_webrtc', () {
|
||||||
|
final params = ConversationParams.decode(sampleVcp)!;
|
||||||
|
final ice = params.iceServers;
|
||||||
|
|
||||||
|
expect(ice, hasLength(2));
|
||||||
|
expect(ice[0]['urls'], 'stun:155.212.205.82:19302');
|
||||||
|
expect(ice[1]['urls'], isA<List<String>>());
|
||||||
|
expect(ice[1]['username'], '1781123373:910215345297');
|
||||||
|
expect(ice[1]['credential'], '9XVm7nRz10AnVFV7kt3M7hgC/xQ=');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input', () {
|
||||||
|
expect(ConversationParams.decode('not-a-vcp'), isNull);
|
||||||
|
expect(ConversationParams.decode(''), isNull);
|
||||||
|
expect(ConversationParams.decode('0:'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
"""mitmproxy addon: dump okcdn call signaling (ws2 WebSocket + HTTP) to a log."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from mitmproxy import http, ctx
|
||||||
|
|
||||||
|
LOG = r"C:\Users\klockky\Komet\docs\ws2_capture.log"
|
||||||
|
HOSTS = ("okcdn.ru", "videowebrtc")
|
||||||
|
|
||||||
|
|
||||||
|
def _interesting(host: str) -> bool:
|
||||||
|
return any(h in host for h in HOSTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _w(line: str) -> None:
|
||||||
|
with open(LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(content: bytes) -> str:
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.dumps(json.loads(text), ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
return "HEX " + content.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_start(flow: http.HTTPFlow) -> None:
|
||||||
|
if not _interesting(flow.request.pretty_host):
|
||||||
|
return
|
||||||
|
_w("=" * 70)
|
||||||
|
_w(f"# WS OPEN {flow.request.pretty_host} {flow.request.path}")
|
||||||
|
_w(f" headers: {dict(flow.request.headers)}")
|
||||||
|
_w("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_message(flow: http.HTTPFlow) -> None:
|
||||||
|
if not _interesting(flow.request.pretty_host):
|
||||||
|
return
|
||||||
|
msg = flow.websocket.messages[-1]
|
||||||
|
arrow = "TX (client->server)" if msg.from_client else "RX (server->client)"
|
||||||
|
ts = time.strftime("%H:%M:%S")
|
||||||
|
_w(f"\n--- {arrow} {ts} {len(msg.content)} B host={flow.request.pretty_host} ---")
|
||||||
|
_w(_fmt(msg.content))
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_end(flow: http.HTTPFlow) -> None:
|
||||||
|
if not _interesting(flow.request.pretty_host):
|
||||||
|
return
|
||||||
|
_w(f"\n# WS CLOSE {flow.request.pretty_host}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def response(flow: http.HTTPFlow) -> None:
|
||||||
|
host = flow.request.pretty_host
|
||||||
|
if not _interesting(host):
|
||||||
|
return
|
||||||
|
if flow.websocket is not None:
|
||||||
|
return
|
||||||
|
_w("\n" + "#" * 70)
|
||||||
|
_w(f"# HTTP {flow.request.method} {host}{flow.request.path} -> {flow.response.status_code}")
|
||||||
|
if flow.request.content:
|
||||||
|
_w(" REQ: " + _fmt(flow.request.content)[:2000])
|
||||||
|
if flow.response.content:
|
||||||
|
_w(" RES: " + _fmt(flow.response.content)[:2000])
|
||||||
|
|
||||||
|
|
||||||
|
def load(loader) -> None:
|
||||||
|
_w(f"\n\n########## capture session start {time.strftime('%Y-%m-%d %H:%M:%S')} ##########")
|
||||||
|
ctx.log.info("ws2_dump addon loaded")
|
||||||
Reference in New Issue
Block a user