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.ACCESS_NETWORK_STATE"/>
|
||||
<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.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
// Backend module for parsing calls from Komet platform
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'contacts.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
|
||||
enum CallStatus { missed, canceled, outgoing, incoming }
|
||||
|
||||
/// Параметры подключения для исходящего звонка (ответ opcode 78).
|
||||
class OutgoingCallParams {
|
||||
final String conversationId;
|
||||
|
||||
/// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
|
||||
final String endpoint;
|
||||
|
||||
/// Наш id в системе звонков (`internalCallerParams.id.internal`).
|
||||
final int callsUserId;
|
||||
|
||||
final int peerExternalId;
|
||||
final bool isVideo;
|
||||
|
||||
const OutgoingCallParams({
|
||||
required this.conversationId,
|
||||
required this.endpoint,
|
||||
required this.callsUserId,
|
||||
required this.peerExternalId,
|
||||
required this.isVideo,
|
||||
});
|
||||
}
|
||||
|
||||
class CallLogEntry {
|
||||
final String id;
|
||||
final int accountId;
|
||||
@@ -32,6 +57,75 @@ class CallsModule {
|
||||
|
||||
CallsModule(this._api);
|
||||
|
||||
/// Инициирует исходящий 1:1 звонок (opcode 78).
|
||||
Future<OutgoingCallParams> initiateCall(
|
||||
int calleeId, {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final conversationId = _uuidV4();
|
||||
final internalParams = jsonEncode({
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'sdkVersion': '2.8.9',
|
||||
'clientAppKey': _clientAppKey(),
|
||||
'platform': 'ANDROID',
|
||||
'protocolVersion': 5,
|
||||
'domainId': '',
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
|
||||
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
|
||||
'conversationId': conversationId,
|
||||
'calleeIds': [calleeId],
|
||||
'internalParams': internalParams,
|
||||
'isVideo': isVideo,
|
||||
});
|
||||
|
||||
if (!response.isOk || response.payload is! Map) {
|
||||
throw Exception('initiateCall: bad response');
|
||||
}
|
||||
final payload = response.payload as Map;
|
||||
|
||||
final icpRaw = payload['internalCallerParams'];
|
||||
final icp = icpRaw is String
|
||||
? jsonDecode(icpRaw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = icp['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw Exception('initiateCall: no endpoint');
|
||||
}
|
||||
|
||||
final id = icp['id'];
|
||||
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
|
||||
final external =
|
||||
(id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? conversationId,
|
||||
endpoint: endpoint,
|
||||
callsUserId: callsUserId,
|
||||
peerExternalId: external,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final r = Random();
|
||||
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
final s = List.generate(16, hex).join();
|
||||
return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
|
||||
'-${s.substring(16, 20)}-${s.substring(20)}';
|
||||
}
|
||||
|
||||
static String _clientAppKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
final r = Random();
|
||||
return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join();
|
||||
}
|
||||
|
||||
/// Fetch call history from opcode 79
|
||||
Future<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
|
||||
@@ -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: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);
|
||||
}
|
||||
|
||||
@@ -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<CallScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late CallScreenState _state;
|
||||
CallSession? _session;
|
||||
StreamSubscription<CallSessionState>? _stateSub;
|
||||
CallSessionState _state = CallSessionState.connecting;
|
||||
bool _incomingPending = false;
|
||||
|
||||
Timer? _timer;
|
||||
int _seconds = 0;
|
||||
bool _isMuted = false;
|
||||
bool _isSpeaker = false;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _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<CallScreen>
|
||||
_pulseAnimation = Tween<double>(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<void> _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<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();
|
||||
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
|
||||
void dispose() {
|
||||
_stateSub?.cancel();
|
||||
_timer?.cancel();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
@@ -97,33 +151,55 @@ class _CallScreenState extends State<CallScreen>
|
||||
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<CallScreen>
|
||||
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<CallScreen>
|
||||
}
|
||||
|
||||
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<CallScreen>
|
||||
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<CallScreen>
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Отмена',
|
||||
color: const Color(0xFFBA1A1A),
|
||||
onTap: _endCall,
|
||||
onTap: _hangup,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -262,13 +342,13 @@ class _CallScreenState extends State<CallScreen>
|
||||
_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<CallScreen>
|
||||
icon: Symbols.phone_disabled,
|
||||
label: 'Завершить',
|
||||
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 '../../../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<ChatScreen> with TickerProviderStateMixin {
|
||||
} 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() {
|
||||
_headerStatusNotifier.value = _headerStatus();
|
||||
}
|
||||
@@ -1194,7 +1236,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.call, weight: 400),
|
||||
onPressed: () {},
|
||||
onPressed: _startCall,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.more_vert, weight: 400),
|
||||
|
||||
@@ -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<DebugMenuScreen> {
|
||||
final Map<String, String> _errors = {};
|
||||
int _cacheSize = 0;
|
||||
bool _clearingCache = false;
|
||||
bool _micSignalOn = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -43,6 +45,18 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
_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 {
|
||||
final size = await MediaCache.currentSize();
|
||||
if (mounted) setState(() => _cacheSize = size);
|
||||
@@ -956,54 +970,46 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+44
-3
@@ -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<KometApp>
|
||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
||||
StreamSubscription<IncomingCall>? _callIncomingSub;
|
||||
Timer? _scheduleTimer;
|
||||
String? _lastVpnNotice;
|
||||
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -233,12 +236,18 @@ class KometAppState extends State<KometApp>
|
||||
});
|
||||
|
||||
_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<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
|
||||
void dispose() {
|
||||
_finishReveal();
|
||||
_sessionExpiredSub?.cancel();
|
||||
_loginStatusSub?.cancel();
|
||||
_vpnBypassSub?.cancel();
|
||||
_callIncomingSub?.cancel();
|
||||
_scheduleTimer?.cancel();
|
||||
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
|
||||
AppAmoled.current.removeListener(_onAmoledChanged);
|
||||
|
||||
+28
-4
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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