feat: звонки 1:1 (аудио) — WebRTC + ws2-сигналинг

Реализованы голосовые звонки 1:1 поверх инфраструктуры VK/OK
(flutter_webrtc + сигналинг ws2 wss://videowebrtc.okcdn.ru). Протокол
восстановлен реверсом захваченных пакетов и декомпиляцией
ru.ok.android.externcalls.sdk.

Ядро (lib/core/calls/):
- conversation_params.dart — декодер vcp (LZ4-блок), с unit-тестом
- ws2_signaling.dart — клиент сигналинга ws2 (transmit-data, ICE,
  accept-call, hangup, change-media-settings, ping/pong)
- call_session.dart — WebRTC-контроллер для звонящего и вызываемого
- call_controller.dart — глобальный оркестратор: пуш opcode 137,
  исходящие, приём/отклонение, управление активной сессией

Бэкенд:
- CallsModule.initiateCall — инициация исходящего (opcode 78)
- общий LZ4-block декодер вынесен в core/protocol/lz4_block.dart
  (используется и транспортом, и vcp)

UI / обвязка:
- CallScreen переписан под живой CallSession (+ кнопка «свернуть»
  без завершения звонка, возврат повторным нажатием 📞)
- кнопка звонка в шапке диалога
- инициализация CallController на логине, показ входящего по пушу
- дев-тумблер: принудительный сигнал состояния микрофона без
  изменения реального аудиотрека

Прочее:
- зависимость flutter_webrtc ^0.12.5
- Android: RECORD_AUDIO / MODIFY_AUDIO_SETTINGS / BLUETOOTH
This commit is contained in:
klockky
2026-06-10 18:21:27 +03:00
parent 3af41dc69e
commit 760cdcd877
16 changed files with 1489 additions and 180 deletions
+4
View File
@@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
+94
View File
@@ -1,10 +1,35 @@
// Backend module for parsing calls from Komet platform // Backend module for parsing calls from Komet platform
import 'dart:convert';
import 'dart:math';
import 'contacts.dart'; import 'contacts.dart';
import '../api.dart'; import '../api.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
enum CallStatus { missed, canceled, outgoing, incoming } enum CallStatus { missed, canceled, outgoing, incoming }
/// Параметры подключения для исходящего звонка (ответ opcode 78).
class OutgoingCallParams {
final String conversationId;
/// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
final String endpoint;
/// Наш id в системе звонков (`internalCallerParams.id.internal`).
final int callsUserId;
final int peerExternalId;
final bool isVideo;
const OutgoingCallParams({
required this.conversationId,
required this.endpoint,
required this.callsUserId,
required this.peerExternalId,
required this.isVideo,
});
}
class CallLogEntry { class CallLogEntry {
final String id; final String id;
final int accountId; final int accountId;
@@ -32,6 +57,75 @@ class CallsModule {
CallsModule(this._api); CallsModule(this._api);
/// Инициирует исходящий 1:1 звонок (opcode 78).
Future<OutgoingCallParams> initiateCall(
int calleeId, {
bool isVideo = false,
}) async {
final conversationId = _uuidV4();
final internalParams = jsonEncode({
'deviceId': _api.deviceId ?? '',
'sdkVersion': '2.8.9',
'clientAppKey': _clientAppKey(),
'platform': 'ANDROID',
'protocolVersion': 5,
'domainId': '',
'capabilities': '3c03f',
});
final response = await _api.sendRequest(Opcode.videoChatStartActive, {
'conversationId': conversationId,
'calleeIds': [calleeId],
'internalParams': internalParams,
'isVideo': isVideo,
});
if (!response.isOk || response.payload is! Map) {
throw Exception('initiateCall: bad response');
}
final payload = response.payload as Map;
final icpRaw = payload['internalCallerParams'];
final icp = icpRaw is String
? jsonDecode(icpRaw) as Map<dynamic, dynamic>
: const <dynamic, dynamic>{};
final endpoint = icp['endpoint'] as String?;
if (endpoint == null) {
throw Exception('initiateCall: no endpoint');
}
final id = icp['id'];
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
final external =
(id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? conversationId,
endpoint: endpoint,
callsUserId: callsUserId,
peerExternalId: external,
isVideo: isVideo,
);
}
static String _uuidV4() {
final r = Random();
final b = List<int>.generate(16, (_) => r.nextInt(256));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
final s = List.generate(16, hex).join();
return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
'-${s.substring(16, 20)}-${s.substring(20)}';
}
static String _clientAppKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
final r = Random();
return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join();
}
/// Fetch call history from opcode 79 /// Fetch call history from opcode 79
Future<List<CallLogEntry>> fetchHistory( Future<List<CallLogEntry>> fetchHistory(
int accountId, int accountId,
+163
View File
@@ -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();
}
}
+299
View File
@@ -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;
}
}
+151
View File
@@ -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,
);
}
}
+279
View File
@@ -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;
}
}
+71
View File
@@ -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);
}
+2 -67
View File
@@ -3,6 +3,7 @@ import 'dart:isolate';
import 'package:dart_lz4/dart_lz4.dart'; import 'package:dart_lz4/dart_lz4.dart';
import 'package:libcompress/libcompress.dart'; import 'package:libcompress/libcompress.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
import 'lz4_block.dart';
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
const int headerSize = 10; const int headerSize = 10;
@@ -203,75 +204,9 @@ Uint8List _decompressPayload(Uint8List src) {
// По умолчанию — LZ4 block (без magic) // По умолчанию — LZ4 block (без magic)
try { try {
return _lz4BlockDecompress(src, _maxDecompressedSize); return lz4BlockDecompress(src, _maxDecompressedSize);
} catch (e) { } catch (e) {
throw Exception('LZ4 block decompression error: $e'); throw Exception('LZ4 block decompression error: $e');
} }
} }
/// LZ4 block декомпрессия (без frame-заголовка).
/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает.
Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) {
var out = Uint8List(1024);
int outLen = 0;
int pos = 0;
void ensure(int extra) {
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
if (outLen + extra <= out.length) return;
var newCap = out.length * 2;
while (newCap < outLen + extra) {
newCap *= 2;
}
if (newCap > maxSize) newCap = maxSize;
final grown = Uint8List(newCap);
grown.setRange(0, outLen, out);
out = grown;
}
while (pos < src.length) {
final token = src[pos++];
var litLen = token >> 4;
if (litLen == 15) {
while (pos < src.length) {
final b = src[pos++];
litLen += b;
if (b != 255) break;
}
}
if (litLen > 0) {
ensure(litLen);
out.setRange(outLen, outLen + litLen, src, pos);
outLen += litLen;
pos += litLen;
}
if (pos >= src.length) break;
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
final offset = src[pos] | (src[pos + 1] << 8);
pos += 2;
if (offset == 0) throw StateError('LZ4: offset = 0');
var matchLen = (token & 0x0F) + 4;
if ((token & 0x0F) == 0x0F) {
while (pos < src.length) {
final b = src[pos++];
matchLen += b;
if (b != 255) break;
}
}
ensure(matchLen);
final start = outLen - offset;
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
for (var i = 0; i < matchLen; i++) {
out[outLen + i] = out[start + i];
}
outLen += matchLen;
}
return Uint8List.sublistView(out, 0, outLen);
}
+144 -64
View File
@@ -2,22 +2,31 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' show Helper;
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/calls/call_session.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
enum CallScreenState { incoming, outgoing, active } /// Экран звонка. Управляется живым [CallSession].
///
/// Открывается в одном из режимов:
/// - исходящий/активный: передан [session] (уже запущен);
/// - входящий: передан [incoming] — показываем «принять/отклонить», сессия
/// создаётся при принятии.
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
final String name; final String name;
final String? avatarUrl; final String? avatarUrl;
final CallScreenState initialState; final CallSession? session;
final IncomingCall? incoming;
const CallScreen({ const CallScreen({
super.key, super.key,
required this.name, required this.name,
this.avatarUrl, this.avatarUrl,
this.initialState = CallScreenState.incoming, this.session,
this.incoming,
}); });
@override @override
@@ -26,18 +35,21 @@ class CallScreen extends StatefulWidget {
class _CallScreenState extends State<CallScreen> class _CallScreenState extends State<CallScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late CallScreenState _state; CallSession? _session;
StreamSubscription<CallSessionState>? _stateSub;
CallSessionState _state = CallSessionState.connecting;
bool _incomingPending = false;
Timer? _timer; Timer? _timer;
int _seconds = 0;
bool _isMuted = false; bool _isMuted = false;
bool _isSpeaker = false; bool _isSpeaker = false;
late AnimationController _pulseController; late AnimationController _pulseController;
late Animation<double> _pulseAnimation; late Animation<double> _pulseAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_state = widget.initialState;
_pulseController = AnimationController( _pulseController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1500), duration: const Duration(milliseconds: 1500),
@@ -45,46 +57,88 @@ class _CallScreenState extends State<CallScreen>
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate( _pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
); );
if (_state == CallScreenState.outgoing) {
_startOutgoingTimer(); _incomingPending = widget.session == null && widget.incoming != null;
if (widget.session != null) _bind(widget.session!);
}
void _bind(CallSession session) {
_session = session;
_state = session.currentState;
_stateSub = session.stateStream.listen(_onState);
if (_state == CallSessionState.active) _startActiveTimer();
}
void _onState(CallSessionState state) {
if (!mounted) return;
setState(() => _state = state);
if (state == CallSessionState.active) {
_startActiveTimer();
} else if (state == CallSessionState.ended) {
_close();
} }
} }
void _startOutgoingTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _seconds++);
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
_timer?.cancel();
setState(() => _state = CallScreenState.active);
_startActiveTimer();
}
});
}
void _startActiveTimer() { void _startActiveTimer() {
_seconds = 0; _timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return; if (!mounted) return;
setState(() => _seconds++); setState(() {});
}); });
} }
void _accept() { Future<void> _accept() async {
final incoming = widget.incoming;
if (incoming == null) return;
setState(() { setState(() {
_state = CallScreenState.active; _incomingPending = false;
_seconds = 0; _state = CallSessionState.connecting;
}); });
_startActiveTimer(); try {
final session = await CallController.instance.acceptIncoming(incoming);
if (!mounted) return;
_bind(session);
} catch (_) {
_close();
}
} }
void _endCall() { Future<void> _decline() async {
final incoming = widget.incoming;
if (incoming != null) {
await CallController.instance.rejectIncoming(incoming);
}
_close();
}
Future<void> _hangup() async {
final session = _session;
if (session != null) {
await session.hangup();
}
_close();
}
void _close() {
if (!mounted) return;
_timer?.cancel(); _timer?.cancel();
Navigator.pop(context); Navigator.of(context).maybePop();
}
Future<void> _toggleMute() async {
final next = !_isMuted;
setState(() => _isMuted = next);
await _session?.setMuted(next);
}
Future<void> _toggleSpeaker() async {
final next = !_isSpeaker;
setState(() => _isSpeaker = next);
await Helper.setSpeakerphoneOn(next);
} }
@override @override
void dispose() { void dispose() {
_stateSub?.cancel();
_timer?.cancel(); _timer?.cancel();
_pulseController.dispose(); _pulseController.dispose();
super.dispose(); super.dispose();
@@ -97,33 +151,55 @@ class _CallScreenState extends State<CallScreen>
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0E0E14), backgroundColor: const Color(0xFF0E0E14),
body: SafeArea( body: SafeArea(
child: Column( child: Stack(
children: [ children: [
const Spacer(flex: 3), Column(
_buildAvatar(screenH), children: [
const SizedBox(height: 24), const Spacer(flex: 3),
_buildName(), _buildAvatar(screenH),
const SizedBox(height: 8), const SizedBox(height: 24),
_buildStatus(), _buildName(),
const Spacer(flex: 2), const SizedBox(height: 8),
_buildActions(), _buildStatus(),
const SizedBox(height: 48), const Spacer(flex: 2),
_buildActions(),
const SizedBox(height: 48),
],
),
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: IconButton(
icon: const Icon(
Symbols.arrow_back,
color: Colors.white,
weight: 400,
),
tooltip: 'Свернуть',
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
], ],
), ),
), ),
); );
} }
bool get _isRinging =>
_incomingPending ||
_state == CallSessionState.connecting ||
_state == CallSessionState.ringing;
Widget _buildAvatar(double screenH) { Widget _buildAvatar(double screenH) {
final size = screenH * 0.18; final size = screenH * 0.18;
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final isRinging = _state == CallScreenState.incoming;
final isOutgoing = _state == CallScreenState.outgoing;
return AnimatedBuilder( return AnimatedBuilder(
animation: _pulseAnimation, animation: _pulseAnimation,
builder: (context, child) { builder: (context, child) {
final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0; final scale = _isRinging ? _pulseAnimation.value : 1.0;
return Transform.scale(scale: scale, child: child); return Transform.scale(scale: scale, child: child);
}, },
child: Container( child: Container(
@@ -189,13 +265,22 @@ class _CallScreenState extends State<CallScreen>
Widget _buildStatus() { Widget _buildStatus() {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
String text; String text;
switch (_state) { if (_incomingPending) {
case CallScreenState.incoming: text = 'Входящий звонок';
text = 'Входящий звонок'; } else {
case CallScreenState.outgoing: switch (_state) {
text = 'Вызов...'; case CallSessionState.connecting:
case CallScreenState.active: text = 'Соединение…';
text = formatSecondsMmSs(_seconds, padMinutes: true); case CallSessionState.ringing:
text = 'Вызов…';
case CallSessionState.active:
text = formatSecondsMmSs(
_session?.elapsedSeconds ?? 0,
padMinutes: true,
);
case CallSessionState.ended:
text = 'Звонок завершён';
}
} }
return Text( return Text(
text, text,
@@ -208,14 +293,9 @@ class _CallScreenState extends State<CallScreen>
} }
Widget _buildActions() { Widget _buildActions() {
switch (_state) { if (_incomingPending) return _buildIncomingActions();
case CallScreenState.incoming: if (_state == CallSessionState.active) return _buildActiveActions();
return _buildIncomingActions(); return _buildOutgoingActions();
case CallScreenState.outgoing:
return _buildOutgoingActions();
case CallScreenState.active:
return _buildActiveActions();
}
} }
Widget _buildIncomingActions() { Widget _buildIncomingActions() {
@@ -226,7 +306,7 @@ class _CallScreenState extends State<CallScreen>
icon: Symbols.phone_disabled, icon: Symbols.phone_disabled,
label: 'Отклонить', label: 'Отклонить',
color: const Color(0xFFBA1A1A), color: const Color(0xFFBA1A1A),
onTap: _endCall, onTap: _decline,
), ),
const SizedBox(width: 48), const SizedBox(width: 48),
_ActionButton( _ActionButton(
@@ -247,7 +327,7 @@ class _CallScreenState extends State<CallScreen>
icon: Symbols.phone_disabled, icon: Symbols.phone_disabled,
label: 'Отмена', label: 'Отмена',
color: const Color(0xFFBA1A1A), color: const Color(0xFFBA1A1A),
onTap: _endCall, onTap: _hangup,
), ),
], ],
); );
@@ -262,13 +342,13 @@ class _CallScreenState extends State<CallScreen>
_CircleActionButton( _CircleActionButton(
icon: _isMuted ? Symbols.mic_off : Symbols.mic, icon: _isMuted ? Symbols.mic_off : Symbols.mic,
active: _isMuted, active: _isMuted,
onTap: () => setState(() => _isMuted = !_isMuted), onTap: _toggleMute,
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
_CircleActionButton( _CircleActionButton(
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up, icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down,
active: _isSpeaker, active: _isSpeaker,
onTap: () => setState(() => _isSpeaker = !_isSpeaker), onTap: _toggleSpeaker,
), ),
const SizedBox(width: 32), const SizedBox(width: 32),
_CircleActionButton( _CircleActionButton(
@@ -283,7 +363,7 @@ class _CallScreenState extends State<CallScreen>
icon: Symbols.phone_disabled, icon: Symbols.phone_disabled,
label: 'Завершить', label: 'Завершить',
color: const Color(0xFFBA1A1A), color: const Color(0xFFBA1A1A),
onTap: _endCall, onTap: _hangup,
), ),
], ],
); );
+43 -1
View File
@@ -19,6 +19,8 @@ import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../../backend/modules/messages.dart'; import '../../../backend/modules/messages.dart';
import '../../../core/calls/call_controller.dart';
import '../calls/call_screen.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart'; import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
@@ -656,6 +658,46 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
} catch (_) {} } catch (_) {}
} }
Future<void> _startCall() async {
if (widget.chatType != 'DIALOG') {
showCustomNotification(context, 'Звонки доступны только в диалогах');
return;
}
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
final active = CallController.instance.activeSession;
if (active != null) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.name,
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
session: active,
),
),
);
return;
}
final peerId = widget.chatId ^ _myId;
if (peerId <= 0) return;
final navigator = Navigator.of(context);
try {
final session = await CallController.instance.startOutgoing(peerId);
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.name,
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
session: session,
),
),
);
} catch (_) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось начать звонок');
}
}
void _recomputeHeaderStatus() { void _recomputeHeaderStatus() {
_headerStatusNotifier.value = _headerStatus(); _headerStatusNotifier.value = _headerStatus();
} }
@@ -1194,7 +1236,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.call, weight: 400), icon: const Icon(Symbols.call, weight: 400),
onPressed: () {}, onPressed: _startCall,
), ),
IconButton( IconButton(
icon: const Icon(Symbols.more_vert, weight: 400), icon: const Icon(Symbols.more_vert, weight: 400),
@@ -19,6 +19,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/login_success_screen.dart'; import '../../widgets/login_success_screen.dart';
import '../calls/call_screen.dart'; import '../calls/call_screen.dart';
import '../../../core/calls/call_controller.dart';
import '../digital_id/digital_id_web_screen.dart'; import '../digital_id/digital_id_web_screen.dart';
class DebugMenuScreen extends StatefulWidget { class DebugMenuScreen extends StatefulWidget {
@@ -36,6 +37,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
final Map<String, String> _errors = {}; final Map<String, String> _errors = {};
int _cacheSize = 0; int _cacheSize = 0;
bool _clearingCache = false; bool _clearingCache = false;
bool _micSignalOn = true;
@override @override
void initState() { void initState() {
@@ -43,6 +45,18 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
_loadCacheSize(); _loadCacheSize();
} }
Future<void> _sendMicSignal(bool enabled) async {
setState(() => _micSignalOn = enabled);
final sent = await CallController.instance.sendMicSignal(enabled);
if (!mounted) return;
showCustomNotification(
context,
sent
? 'Сигнал микрофона: ${enabled ? 'ВКЛ' : 'ВЫКЛ'} отправлен'
: 'Нет активного звонка',
);
}
Future<void> _loadCacheSize() async { Future<void> _loadCacheSize() async {
final size = await MediaCache.currentSize(); final size = await MediaCache.currentSize();
if (mounted) setState(() => _cacheSize = size); if (mounted) setState(() => _cacheSize = size);
@@ -956,54 +970,46 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_DebugCallButton(
label: 'Экран звонка (превью)',
icon: Symbols.phone,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(name: 'Кирил Г.'),
),
),
),
const SizedBox(height: 16),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _DebugCallButton( child: Column(
label: 'Входящий', crossAxisAlignment: CrossAxisAlignment.start,
icon: Symbols.call_received, children: [
onTap: () => Navigator.push( Text(
context, 'Сигнал микрофона (тест)',
MaterialPageRoute( style: TextStyle(
builder: (_) => const CallScreen( color: cs.onSurface,
name: 'Кирил Г.', fontSize: 16,
initialState: CallScreenState.incoming, fontWeight: FontWeight.w500,
), ),
), ),
), const SizedBox(height: 2),
Text(
'Шлёт change-media-settings в активный звонок, '
'не меняя реальный микрофон',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
), ),
), ),
const SizedBox(width: 8), Switch(
Expanded( value: _micSignalOn,
child: _DebugCallButton( onChanged: _sendMicSignal,
label: 'Исходящий',
icon: Symbols.call_made,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(
name: 'Кирил Г.',
initialState: CallScreenState.outgoing,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: _DebugCallButton(
label: 'Активный',
icon: Symbols.phone_in_talk,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(
name: 'Кирил Г.',
initialState: CallScreenState.active,
),
),
),
),
), ),
], ],
), ),
+44 -3
View File
@@ -33,6 +33,8 @@ import 'backend/modules/messages.dart';
import 'backend/modules/polls.dart'; import 'backend/modules/polls.dart';
import 'backend/modules/webapp.dart'; import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart'; import 'backend/modules/digital_id.dart';
import 'core/calls/call_controller.dart';
import 'frontend/screens/calls/call_screen.dart';
import 'core/push/push_service.dart'; import 'core/push/push_service.dart';
import 'core/storage/app_database.dart'; import 'core/storage/app_database.dart';
import 'core/transport/tls_config.dart'; import 'core/transport/tls_config.dart';
@@ -189,6 +191,7 @@ class KometAppState extends State<KometApp>
StreamSubscription<SessionExpiredException>? _sessionExpiredSub; StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub; StreamSubscription<LoginStatus>? _loginStatusSub;
StreamSubscription<VpnBypassResult>? _vpnBypassSub; StreamSubscription<VpnBypassResult>? _vpnBypassSub;
StreamSubscription<IncomingCall>? _callIncomingSub;
Timer? _scheduleTimer; Timer? _scheduleTimer;
String? _lastVpnNotice; String? _lastVpnNotice;
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
@@ -233,12 +236,18 @@ class KometAppState extends State<KometApp>
}); });
_loginStatusSub = accountModule.loginStatusStream.listen((status) async { _loginStatusSub = accountModule.loginStatusStream.listen((status) async {
if (status == LoginStatus.success && isOnemeFlavor) { if (status == LoginStatus.success) {
await PushService.instance.init(api: api, account: accountModule); CallController.instance.init(api);
await PushService.instance.onLoginSuccess(); if (isOnemeFlavor) {
await PushService.instance.init(api: api, account: accountModule);
await PushService.instance.onLoginSuccess();
}
} }
}); });
_callIncomingSub =
CallController.instance.incomingCalls.listen(_onIncomingCall);
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async { _sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
if (_isLoggingOut) return; if (_isLoggingOut) return;
_isLoggingOut = true; _isLoggingOut = true;
@@ -287,12 +296,44 @@ class KometAppState extends State<KometApp>
}); });
} }
Future<void> _onIncomingCall(IncomingCall call) async {
String name = 'Входящий звонок';
String? avatar;
try {
final profile = await AppDatabase.loadActiveProfile();
if (profile != null) {
final contacts = await ContactsModule.getContacts(profile.id);
for (final c in contacts) {
if (c.id == call.callerId) {
final full = '${c.firstName} ${c.lastName ?? ''}'.trim();
if (full.isNotEmpty) name = full;
avatar = c.baseUrl;
break;
}
}
}
} catch (_) {}
final navState = KometApp.navigatorKey.currentState;
if (navState == null) return;
navState.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: name,
avatarUrl: avatar,
incoming: call,
),
),
);
}
@override @override
void dispose() { void dispose() {
_finishReveal(); _finishReveal();
_sessionExpiredSub?.cancel(); _sessionExpiredSub?.cancel();
_loginStatusSub?.cancel(); _loginStatusSub?.cancel();
_vpnBypassSub?.cancel(); _vpnBypassSub?.cancel();
_callIncomingSub?.cancel();
_scheduleTimer?.cancel(); _scheduleTimer?.cancel();
AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
AppAmoled.current.removeListener(_onAmoledChanged); AppAmoled.current.removeListener(_onAmoledChanged);
+28 -4
View File
@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.dev"
source: hosted
version: "1.8.1"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -517,6 +525,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b
url: "https://pub.dev"
source: hosted
version: "0.12.12+hotfix.1"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -721,10 +737,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mobile_scanner: mobile_scanner:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1110,10 +1126,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1210,6 +1226,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.dev"
source: hosted
version: "1.5.1"
win32: win32:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -66,6 +66,7 @@ dependencies:
firebase_messaging: ^16.0.2 firebase_messaging: ^16.0.2
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
flutter_webrtc: ^0.12.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+47
View File
@@ -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
View File
@@ -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")