From cf154b006b33a95c1eeb09628588efced10c4dbe Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 10 Jun 2026 18:21:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?1:1=20(=D0=B0=D1=83=D0=B4=D0=B8=D0=BE)=20=E2=80=94=20WebRTC=20+?= =?UTF-8?q?=20ws2-=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы голосовые звонки 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 --- android/app/src/main/AndroidManifest.xml | 4 + lib/backend/modules/calls.dart | 94 ++++++ lib/core/calls/call_controller.dart | 163 ++++++++++ lib/core/calls/call_session.dart | 299 ++++++++++++++++++ lib/core/calls/conversation_params.dart | 151 +++++++++ lib/core/calls/ws2_signaling.dart | 279 ++++++++++++++++ lib/core/protocol/lz4_block.dart | 71 +++++ lib/core/protocol/packet.dart | 69 +--- lib/frontend/screens/calls/call_screen.dart | 208 ++++++++---- lib/frontend/screens/chats/chat_screen.dart | 44 ++- .../screens/profile/debug_menu_screen.dart | 88 +++--- lib/main.dart | 47 ++- pubspec.lock | 32 +- pubspec.yaml | 1 + test/conversation_params_test.dart | 47 +++ ws2_dump.py | 72 +++++ 16 files changed, 1489 insertions(+), 180 deletions(-) create mode 100644 lib/core/calls/call_controller.dart create mode 100644 lib/core/calls/call_session.dart create mode 100644 lib/core/calls/conversation_params.dart create mode 100644 lib/core/calls/ws2_signaling.dart create mode 100644 lib/core/protocol/lz4_block.dart create mode 100644 test/conversation_params_test.dart create mode 100644 ws2_dump.py diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4ab092f..1a522ad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index cc2db6a..4787aff 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -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 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 + : const {}; + + 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.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> fetchHistory( int accountId, diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart new file mode 100644 index 0000000..cb7ccee --- /dev/null +++ b/lib/core/calls/call_controller.dart @@ -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? _pushSub; + + final _incoming = StreamController.broadcast(); + final _ended = StreamController.broadcast(); + + /// Новый входящий звонок — UI показывает экран/оверлей. + Stream get incomingCalls => _incoming.stream; + + /// Активный звонок завершился (любой стороной). + Stream 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 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 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 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 endActive() => _active?.hangup() ?? Future.value(); + + /// DEBUG: послать в активный звонок сигнал состояния микрофона + /// (`change-media-settings`), не трогая реальный микрофон. + /// Возвращает `false`, если активного звонка нет. + Future 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(); + } +} diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart new file mode 100644 index 0000000..715c2cd --- /dev/null +++ b/lib/core/calls/call_session.dart @@ -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.broadcast(); + final _remoteStream = StreamController.broadcast(); + + Stream get stateStream => _state.stream; + Stream 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 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 _onNotification(Map 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 _onConnection(Map 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 _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 _onTransmittedData(Map 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 accept() async { + if (_accepted) return; + _accepted = true; + await _signaling?.acceptCall(); + await _signaling?.changeMediaSettings(isAudioEnabled: !_muted); + _setState(CallSessionState.active); + } + + /// DEBUG: отправить серверу сигнал `change-media-settings` с заданным + /// состоянием микрофона, НЕ трогая реальный аудиотрек. + Future sendAudioEnabledSignal(bool enabled) async { + await _signaling?.changeMediaSettings(isAudioEnabled: enabled); + } + + Future setMuted(bool muted) async { + _muted = muted; + for (final track in _localStream?.getAudioTracks() ?? []) { + track.enabled = !muted; + } + await _signaling?.changeMediaSettings(isAudioEnabled: !muted); + } + + Future 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 _dispose() async { + for (final track in _localStream?.getTracks() ?? []) { + 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()) { + 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>? _iceServersFrom(Object? convParams) { + if (convParams is! Map) return null; + final servers = >[]; + 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; + } +} diff --git a/lib/core/calls/conversation_params.dart b/lib/core/calls/conversation_params.dart new file mode 100644 index 0000000..64a9e2a --- /dev/null +++ b/lib/core/calls/conversation_params.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../protocol/lz4_block.dart'; + +/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше +/// входящего звонка (opcode 137) и в ответе на инициацию исходящего. +/// +/// Формат строки: `:`. После распаковки — +/// компактный 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 wsIps; + + /// HTTP/3 web-transport fallback, напр. `https://videowebrtc.okcdn.ru:23456/wt`. + final String? wtEndpoint; + final List wtIps; + + /// API звонков, напр. `https://calls.okcdn.ru`. + final String? callsApiEndpoint; + final List callsApiIps; + + /// Тип клиента, напр. `one_me`. + final String? clientType; + + /// Время истечения параметров (unix-секунды). + final int? expiresAt; + + final String? stun; + final List 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> get iceServers { + final servers = >[]; + 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 _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 _stringList(Object? value) { + if (value is! List) return const []; + return value.whereType().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, + ); + } +} diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart new file mode 100644 index 0000000..177c33c --- /dev/null +++ b/lib/core/calls/ws2_signaling.dart @@ -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": "", "type": "response"}` +/// - пуш: `{..., "notification": "", "type": "notification"}` +/// - keepalive: текстовый кадр `ping` → ответ `pong`. +class Ws2Signaling { + final Ws2Config config; + + WebSocket? _socket; + int _sequence = 0; + final Map>> _pending = {}; + + final _notifications = StreamController>.broadcast(); + final _closed = Completer(); + + Ws2Signaling(this.config); + + /// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`. + Stream> get notifications => _notifications.stream; + + /// Завершается, когда сокет закрыт (значение — причина закрытия, если была). + Future get done => _closed.future; + + bool get isConnected => _socket != null; + + Future 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) { + text = utf8.decode(frame); + } else { + return; + } + + Object? decoded; + try { + decoded = jsonDecode(text); + } catch (_) { + return; + } + if (decoded is! Map) 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> sendCommand( + String command, { + Map 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>(); + _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 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 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 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 acceptCall() => sendCommand('accept-call'); + + Future hangup({String reason = 'HUNGUP'}) => + sendCommand('hangup', extra: {'reason': reason}); + + Future close() async { + await _socket?.close(); + _socket = null; + } +} diff --git a/lib/core/protocol/lz4_block.dart b/lib/core/protocol/lz4_block.dart new file mode 100644 index 0000000..55d1529 --- /dev/null +++ b/lib/core/protocol/lz4_block.dart @@ -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); +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 5e16093..90dec0c 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -3,6 +3,7 @@ import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:libcompress/libcompress.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; +import 'lz4_block.dart'; /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 const int headerSize = 10; @@ -203,75 +204,9 @@ Uint8List _decompressPayload(Uint8List src) { // По умолчанию — LZ4 block (без magic) try { - return _lz4BlockDecompress(src, _maxDecompressedSize); + return lz4BlockDecompress(src, _maxDecompressedSize); } catch (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); -} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 43f0ec1..2470392 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -2,22 +2,31 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' show Helper; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/calls/call_controller.dart'; +import '../../../core/calls/call_session.dart'; import '../../../core/utils/format.dart'; -enum CallScreenState { incoming, outgoing, active } - +/// Экран звонка. Управляется живым [CallSession]. +/// +/// Открывается в одном из режимов: +/// - исходящий/активный: передан [session] (уже запущен); +/// - входящий: передан [incoming] — показываем «принять/отклонить», сессия +/// создаётся при принятии. class CallScreen extends StatefulWidget { final String name; final String? avatarUrl; - final CallScreenState initialState; + final CallSession? session; + final IncomingCall? incoming; const CallScreen({ super.key, required this.name, this.avatarUrl, - this.initialState = CallScreenState.incoming, + this.session, + this.incoming, }); @override @@ -26,18 +35,21 @@ class CallScreen extends StatefulWidget { class _CallScreenState extends State with SingleTickerProviderStateMixin { - late CallScreenState _state; + CallSession? _session; + StreamSubscription? _stateSub; + CallSessionState _state = CallSessionState.connecting; + bool _incomingPending = false; + Timer? _timer; - int _seconds = 0; bool _isMuted = false; bool _isSpeaker = false; + late AnimationController _pulseController; late Animation _pulseAnimation; @override void initState() { super.initState(); - _state = widget.initialState; _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -45,46 +57,88 @@ class _CallScreenState extends State _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( 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() { - _seconds = 0; - _timer = Timer.periodic(const Duration(seconds: 1), (_) { + _timer ??= Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; - setState(() => _seconds++); + setState(() {}); }); } - void _accept() { + Future _accept() async { + final incoming = widget.incoming; + if (incoming == null) return; setState(() { - _state = CallScreenState.active; - _seconds = 0; + _incomingPending = false; + _state = CallSessionState.connecting; }); - _startActiveTimer(); + try { + final session = await CallController.instance.acceptIncoming(incoming); + if (!mounted) return; + _bind(session); + } catch (_) { + _close(); + } } - void _endCall() { + Future _decline() async { + final incoming = widget.incoming; + if (incoming != null) { + await CallController.instance.rejectIncoming(incoming); + } + _close(); + } + + Future _hangup() async { + final session = _session; + if (session != null) { + await session.hangup(); + } + _close(); + } + + void _close() { + if (!mounted) return; _timer?.cancel(); - Navigator.pop(context); + Navigator.of(context).maybePop(); + } + + Future _toggleMute() async { + final next = !_isMuted; + setState(() => _isMuted = next); + await _session?.setMuted(next); + } + + Future _toggleSpeaker() async { + final next = !_isSpeaker; + setState(() => _isSpeaker = next); + await Helper.setSpeakerphoneOn(next); } @override void dispose() { + _stateSub?.cancel(); _timer?.cancel(); _pulseController.dispose(); super.dispose(); @@ -97,33 +151,55 @@ class _CallScreenState extends State return Scaffold( backgroundColor: const Color(0xFF0E0E14), body: SafeArea( - child: Column( + child: Stack( children: [ - const Spacer(flex: 3), - _buildAvatar(screenH), - const SizedBox(height: 24), - _buildName(), - const SizedBox(height: 8), - _buildStatus(), - const Spacer(flex: 2), - _buildActions(), - const SizedBox(height: 48), + Column( + children: [ + const Spacer(flex: 3), + _buildAvatar(screenH), + const SizedBox(height: 24), + _buildName(), + const SizedBox(height: 8), + _buildStatus(), + 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) { final size = screenH * 0.18; final cs = Theme.of(context).colorScheme; - final isRinging = _state == CallScreenState.incoming; - final isOutgoing = _state == CallScreenState.outgoing; return AnimatedBuilder( animation: _pulseAnimation, 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); }, child: Container( @@ -189,13 +265,22 @@ class _CallScreenState extends State Widget _buildStatus() { final cs = Theme.of(context).colorScheme; String text; - switch (_state) { - case CallScreenState.incoming: - text = 'Входящий звонок'; - case CallScreenState.outgoing: - text = 'Вызов...'; - case CallScreenState.active: - text = formatSecondsMmSs(_seconds, padMinutes: true); + if (_incomingPending) { + text = 'Входящий звонок'; + } else { + switch (_state) { + case CallSessionState.connecting: + text = 'Соединение…'; + case CallSessionState.ringing: + text = 'Вызов…'; + case CallSessionState.active: + text = formatSecondsMmSs( + _session?.elapsedSeconds ?? 0, + padMinutes: true, + ); + case CallSessionState.ended: + text = 'Звонок завершён'; + } } return Text( text, @@ -208,14 +293,9 @@ class _CallScreenState extends State } Widget _buildActions() { - switch (_state) { - case CallScreenState.incoming: - return _buildIncomingActions(); - case CallScreenState.outgoing: - return _buildOutgoingActions(); - case CallScreenState.active: - return _buildActiveActions(); - } + if (_incomingPending) return _buildIncomingActions(); + if (_state == CallSessionState.active) return _buildActiveActions(); + return _buildOutgoingActions(); } Widget _buildIncomingActions() { @@ -226,7 +306,7 @@ class _CallScreenState extends State icon: Symbols.phone_disabled, label: 'Отклонить', color: const Color(0xFFBA1A1A), - onTap: _endCall, + onTap: _decline, ), const SizedBox(width: 48), _ActionButton( @@ -247,7 +327,7 @@ class _CallScreenState extends State icon: Symbols.phone_disabled, label: 'Отмена', color: const Color(0xFFBA1A1A), - onTap: _endCall, + onTap: _hangup, ), ], ); @@ -262,13 +342,13 @@ class _CallScreenState extends State _CircleActionButton( icon: _isMuted ? Symbols.mic_off : Symbols.mic, active: _isMuted, - onTap: () => setState(() => _isMuted = !_isMuted), + onTap: _toggleMute, ), const SizedBox(width: 32), _CircleActionButton( - icon: _isMuted ? Symbols.volume_off : Symbols.volume_up, + icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down, active: _isSpeaker, - onTap: () => setState(() => _isSpeaker = !_isSpeaker), + onTap: _toggleSpeaker, ), const SizedBox(width: 32), _CircleActionButton( @@ -283,7 +363,7 @@ class _CallScreenState extends State icon: Symbols.phone_disabled, label: 'Завершить', color: const Color(0xFFBA1A1A), - onTap: _endCall, + onTap: _hangup, ), ], ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6209ee0..1dede39 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -19,6 +19,8 @@ import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.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/packet.dart'; import '../../../core/storage/app_database.dart'; @@ -656,6 +658,46 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } catch (_) {} } + Future _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() { _headerStatusNotifier.value = _headerStatus(); } @@ -1194,7 +1236,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { actions: [ IconButton( icon: const Icon(Symbols.call, weight: 400), - onPressed: () {}, + onPressed: _startCall, ), IconButton( icon: const Icon(Symbols.more_vert, weight: 400), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 92909a2..c366bb5 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -19,6 +19,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; +import '../../../core/calls/call_controller.dart'; import '../digital_id/digital_id_web_screen.dart'; class DebugMenuScreen extends StatefulWidget { @@ -36,6 +37,7 @@ class _DebugMenuScreenState extends State { final Map _errors = {}; int _cacheSize = 0; bool _clearingCache = false; + bool _micSignalOn = true; @override void initState() { @@ -43,6 +45,18 @@ class _DebugMenuScreenState extends State { _loadCacheSize(); } + Future _sendMicSignal(bool enabled) async { + setState(() => _micSignalOn = enabled); + final sent = await CallController.instance.sendMicSignal(enabled); + if (!mounted) return; + showCustomNotification( + context, + sent + ? 'Сигнал микрофона: ${enabled ? 'ВКЛ' : 'ВЫКЛ'} отправлен' + : 'Нет активного звонка', + ); + } + Future _loadCacheSize() async { final size = await MediaCache.currentSize(); if (mounted) setState(() => _cacheSize = size); @@ -956,54 +970,46 @@ class _DebugMenuScreenState extends State { ), ), const SizedBox(height: 12), + _DebugCallButton( + label: 'Экран звонка (превью)', + icon: Symbols.phone, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen(name: 'Кирил Г.'), + ), + ), + ), + const SizedBox(height: 16), Row( children: [ Expanded( - child: _DebugCallButton( - label: 'Входящий', - icon: Symbols.call_received, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const CallScreen( - name: 'Кирил Г.', - initialState: CallScreenState.incoming, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Сигнал микрофона (тест)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, ), ), - ), + const SizedBox(height: 2), + Text( + 'Шлёт change-media-settings в активный звонок, ' + 'не меняя реальный микрофон', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], ), ), - const SizedBox(width: 8), - Expanded( - child: _DebugCallButton( - 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, - ), - ), - ), - ), + Switch( + value: _micSignalOn, + onChanged: _sendMicSignal, ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 91cf87a..78a4f9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,8 @@ import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/webapp.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/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -189,6 +191,7 @@ class KometAppState extends State StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; + StreamSubscription? _callIncomingSub; Timer? _scheduleTimer; String? _lastVpnNotice; DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); @@ -233,12 +236,18 @@ class KometAppState extends State }); _loginStatusSub = accountModule.loginStatusStream.listen((status) async { - if (status == LoginStatus.success && isOnemeFlavor) { - await PushService.instance.init(api: api, account: accountModule); - await PushService.instance.onLoginSuccess(); + if (status == LoginStatus.success) { + CallController.instance.init(api); + 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 { if (_isLoggingOut) return; _isLoggingOut = true; @@ -287,12 +296,44 @@ class KometAppState extends State }); } + Future _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 void dispose() { _finishReveal(); _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); + _callIncomingSub?.cancel(); _scheduleTimer?.cancel(); AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppAmoled.current.removeListener(_onAmoledChanged); diff --git a/pubspec.lock b/pubspec.lock index b3a35dd..c150e8c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -517,6 +525,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -721,10 +737,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mobile_scanner: dependency: "direct main" description: @@ -1110,10 +1126,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" timezone: dependency: "direct main" description: @@ -1210,6 +1226,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 44892b7..a333c02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 flutter_inappwebview: ^6.1.5 + flutter_webrtc: ^0.12.5 dev_dependencies: flutter_test: diff --git a/test/conversation_params_test.dart b/test/conversation_params_test.dart new file mode 100644 index 0000000..05a4332 --- /dev/null +++ b/test/conversation_params_test.dart @@ -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>()); + 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); + }); +} diff --git a/ws2_dump.py b/ws2_dump.py new file mode 100644 index 0000000..2695067 --- /dev/null +++ b/ws2_dump.py @@ -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")