diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4ab092f..1a522ad 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
+
+
+
+
diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart
index cc2db6a..4787aff 100644
--- a/lib/backend/modules/calls.dart
+++ b/lib/backend/modules/calls.dart
@@ -1,10 +1,35 @@
// Backend module for parsing calls from Komet platform
+import 'dart:convert';
+import 'dart:math';
+
import 'contacts.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
enum CallStatus { missed, canceled, outgoing, incoming }
+/// Параметры подключения для исходящего звонка (ответ opcode 78).
+class OutgoingCallParams {
+ final String conversationId;
+
+ /// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`).
+ final String endpoint;
+
+ /// Наш id в системе звонков (`internalCallerParams.id.internal`).
+ final int callsUserId;
+
+ final int peerExternalId;
+ final bool isVideo;
+
+ const OutgoingCallParams({
+ required this.conversationId,
+ required this.endpoint,
+ required this.callsUserId,
+ required this.peerExternalId,
+ required this.isVideo,
+ });
+}
+
class CallLogEntry {
final String id;
final int accountId;
@@ -32,6 +57,75 @@ class CallsModule {
CallsModule(this._api);
+ /// Инициирует исходящий 1:1 звонок (opcode 78).
+ Future initiateCall(
+ int calleeId, {
+ bool isVideo = false,
+ }) async {
+ final conversationId = _uuidV4();
+ final internalParams = jsonEncode({
+ 'deviceId': _api.deviceId ?? '',
+ 'sdkVersion': '2.8.9',
+ 'clientAppKey': _clientAppKey(),
+ 'platform': 'ANDROID',
+ 'protocolVersion': 5,
+ 'domainId': '',
+ 'capabilities': '3c03f',
+ });
+
+ final response = await _api.sendRequest(Opcode.videoChatStartActive, {
+ 'conversationId': conversationId,
+ 'calleeIds': [calleeId],
+ 'internalParams': internalParams,
+ 'isVideo': isVideo,
+ });
+
+ if (!response.isOk || response.payload is! Map) {
+ throw Exception('initiateCall: bad response');
+ }
+ final payload = response.payload as Map;
+
+ final icpRaw = payload['internalCallerParams'];
+ final icp = icpRaw is String
+ ? jsonDecode(icpRaw) as Map
+ : const {};
+
+ final endpoint = icp['endpoint'] as String?;
+ if (endpoint == null) {
+ throw Exception('initiateCall: no endpoint');
+ }
+
+ final id = icp['id'];
+ final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
+ final external =
+ (id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId;
+
+ return OutgoingCallParams(
+ conversationId: (payload['conversationId'] as String?) ?? conversationId,
+ endpoint: endpoint,
+ callsUserId: callsUserId,
+ peerExternalId: external,
+ isVideo: isVideo,
+ );
+ }
+
+ static String _uuidV4() {
+ final r = Random();
+ final b = List.generate(16, (_) => r.nextInt(256));
+ b[6] = (b[6] & 0x0f) | 0x40;
+ b[8] = (b[8] & 0x3f) | 0x80;
+ String hex(int i) => b[i].toRadixString(16).padLeft(2, '0');
+ final s = List.generate(16, hex).join();
+ return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}'
+ '-${s.substring(16, 20)}-${s.substring(20)}';
+ }
+
+ static String _clientAppKey() {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ final r = Random();
+ return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join();
+ }
+
/// Fetch call history from opcode 79
Future> fetchHistory(
int accountId,
diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart
new file mode 100644
index 0000000..cb7ccee
--- /dev/null
+++ b/lib/core/calls/call_controller.dart
@@ -0,0 +1,163 @@
+import 'dart:async';
+
+import '../../backend/api.dart';
+import '../../backend/modules/calls.dart';
+import '../protocol/opcode_map.dart';
+import '../protocol/packet.dart';
+import 'call_session.dart';
+import 'conversation_params.dart';
+import 'ws2_signaling.dart';
+
+/// Данные входящего звонка (из пуша opcode 137).
+class IncomingCall {
+ final String conversationId;
+
+ /// ONE_ME id звонящего.
+ final int callerId;
+ final bool isVideo;
+ final ConversationParams params;
+
+ const IncomingCall({
+ required this.conversationId,
+ required this.callerId,
+ required this.isVideo,
+ required this.params,
+ });
+}
+
+/// Глобальный оркестратор звонков: слушает входящие (opcode 137),
+/// инициирует исходящие (opcode 78) и держит активный [CallSession].
+class CallController {
+ CallController._();
+ static final CallController instance = CallController._();
+
+ Api? _api;
+ CallsModule? _calls;
+ StreamSubscription? _pushSub;
+
+ final _incoming = StreamController.broadcast();
+ final _ended = StreamController.broadcast();
+
+ /// Новый входящий звонок — UI показывает экран/оверлей.
+ Stream get incomingCalls => _incoming.stream;
+
+ /// Активный звонок завершился (любой стороной).
+ Stream get callEnded => _ended.stream;
+
+ CallSession? _active;
+ CallSession? get activeSession => _active;
+
+ IncomingCall? _pending;
+ IncomingCall? get pendingIncoming => _pending;
+
+ bool get isBusy => _active != null;
+
+ void init(Api api) {
+ if (_api != null) return;
+ _api = api;
+ _calls = CallsModule(api);
+ _pushSub = api.pushStream.listen(_onPush);
+ }
+
+ void _onPush(Packet packet) {
+ if (packet.opcode != Opcode.notifCallStart) return;
+ final payload = packet.payload;
+ if (payload is! Map) return;
+
+ final vcp = payload['vcp'] as String?;
+ final conversationId = payload['conversationId'] as String?;
+ final callerId = payload['callerId'] as int?;
+ if (vcp == null || conversationId == null || callerId == null) return;
+
+ final params = ConversationParams.decode(vcp);
+ if (params == null) return;
+
+ // Уже идёт звонок — новый игнорируем (сервер сам отметит как пропущенный).
+ if (_active != null) return;
+
+ final incoming = IncomingCall(
+ conversationId: conversationId,
+ callerId: callerId,
+ isVideo: payload['type'] == 'VIDEO',
+ params: params,
+ );
+ _pending = incoming;
+ _incoming.add(incoming);
+ }
+
+ /// Начать исходящий 1:1 звонок.
+ Future startOutgoing(int calleeId, {bool isVideo = false}) async {
+ if (_active != null) throw StateError('уже идёт звонок');
+ final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
+ final config = Ws2Config.fromEndpoint(out.endpoint, userId: out.callsUserId);
+ final session = CallSession(ws2Config: config, role: CallRole.caller);
+ _bind(session);
+ await session.start();
+ return session;
+ }
+
+ /// Принять входящий звонок.
+ Future acceptIncoming(IncomingCall call) async {
+ _pending = null;
+ final config = Ws2Config.fromVcp(
+ call.params,
+ conversationId: call.conversationId,
+ );
+ final session = CallSession(
+ ws2Config: config,
+ params: call.params,
+ role: CallRole.callee,
+ );
+ _bind(session);
+ await session.start();
+ await session.accept();
+ return session;
+ }
+
+ /// Отклонить входящий звонок (подключаемся к ws2 только чтобы отправить
+ /// `hangup reason=REJECTED`, без медиа).
+ Future rejectIncoming(IncomingCall call) async {
+ _pending = null;
+ final config = Ws2Config.fromVcp(
+ call.params,
+ conversationId: call.conversationId,
+ );
+ final signaling = Ws2Signaling(config);
+ try {
+ await signaling.connect();
+ await signaling.hangup(reason: 'REJECTED');
+ } catch (_) {
+ } finally {
+ await signaling.close();
+ }
+ }
+
+ /// Завершить активный звонок.
+ Future endActive() => _active?.hangup() ?? Future.value();
+
+ /// DEBUG: послать в активный звонок сигнал состояния микрофона
+ /// (`change-media-settings`), не трогая реальный микрофон.
+ /// Возвращает `false`, если активного звонка нет.
+ Future sendMicSignal(bool enabled) async {
+ final session = _active;
+ if (session == null) return false;
+ await session.sendAudioEnabledSignal(enabled);
+ return true;
+ }
+
+ void _bind(CallSession session) {
+ _active = session;
+ session.stateStream.listen((state) {
+ if (state == CallSessionState.ended && _active == session) {
+ _active = null;
+ _ended.add(null);
+ }
+ });
+ }
+
+ void dispose() {
+ _pushSub?.cancel();
+ _incoming.close();
+ _ended.close();
+ }
+}
diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart
new file mode 100644
index 0000000..715c2cd
--- /dev/null
+++ b/lib/core/calls/call_session.dart
@@ -0,0 +1,299 @@
+import 'dart:async';
+
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+
+import 'conversation_params.dart';
+import 'ws2_signaling.dart';
+
+enum CallRole { caller, callee }
+
+enum CallSessionState { connecting, ringing, active, ended }
+
+/// Один сеанс 1:1 аудиозвонка: связывает сигналинг [Ws2Signaling] с
+/// `RTCPeerConnection`.
+///
+/// Поток (подтверждён захватом `docs/ws2_capture.log` для звонящего и
+/// реконструирован из `ru.ok.android.externcalls.sdk` для вызываемого):
+/// - сервер шлёт `connection` → берём ICE-сервера и id собеседника;
+/// - звонящий: createOffer → `transmit-data`(offer); ждёт `transmitted-data`(answer);
+/// - вызываемый: `transmitted-data`(offer) → createAnswer → `transmit-data`(answer);
+/// - обе стороны: ICE-кандидаты через `transmit-data`, приём — через `transmitted-data`;
+/// - вызываемый по тапу «принять» шлёт `accept-call`.
+class CallSession {
+ final Ws2Config ws2Config;
+
+ /// Параметры из `vcp` (входящий звонок) — резервный источник ICE-серверов,
+ /// если их нет в пуше `connection`. Для исходящего может быть `null`.
+ final ConversationParams? params;
+ final CallRole role;
+
+ CallSession({
+ required this.ws2Config,
+ required this.role,
+ this.params,
+ });
+
+ Ws2Signaling? _signaling;
+ RTCPeerConnection? _pc;
+ MediaStream? _localStream;
+
+ int? _peerId;
+ String _peerType = 'USER';
+ int _peerDeviceIdx = 0;
+
+ bool _muted = false;
+ bool _accepted = false;
+
+ final _state = StreamController.broadcast();
+ final _remoteStream = StreamController.broadcast();
+
+ Stream get stateStream => _state.stream;
+ Stream get remoteStreamStream => _remoteStream.stream;
+ bool get isMuted => _muted;
+
+ CallSessionState _current = CallSessionState.connecting;
+ DateTime? _activeSince;
+
+ /// Текущее состояние (для переоткрытия свёрнутого экрана —
+ /// broadcast-поток не отдаёт последнее значение новым слушателям).
+ CallSessionState get currentState => _current;
+
+ /// Длительность разговора в секундах (0, пока не активен).
+ int get elapsedSeconds =>
+ _activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds;
+
+ void _setState(CallSessionState s) {
+ if (_current == s || _current == CallSessionState.ended) return;
+ if (s == CallSessionState.active) _activeSince ??= DateTime.now();
+ _current = s;
+ _state.add(s);
+ }
+
+ Future start() async {
+ _setState(CallSessionState.connecting);
+ final signaling = Ws2Signaling(ws2Config);
+ _signaling = signaling;
+ signaling.notifications.listen(_onNotification, onError: (_) => _end());
+ signaling.done.then((_) => _end());
+ await signaling.connect();
+ }
+
+ Future _onNotification(Map msg) async {
+ switch (msg['notification']) {
+ case 'connection':
+ await _onConnection(msg);
+ break;
+ case 'transmitted-data':
+ await _onTransmittedData(msg);
+ break;
+ case 'accepted-call':
+ _setState(CallSessionState.active);
+ break;
+ case 'closed-conversation':
+ _end();
+ break;
+ }
+ }
+
+ Future _onConnection(Map msg) async {
+ final convParams = msg['conversationParams'];
+ final conversation = msg['conversation'];
+
+ final iceServers =
+ _iceServersFrom(convParams) ?? params?.iceServers ?? const [];
+ _resolvePeer(conversation);
+
+ final pc = await createPeerConnection({
+ 'iceServers': iceServers,
+ 'sdpSemantics': 'unified-plan',
+ });
+ _pc = pc;
+
+ _localStream = await navigator.mediaDevices.getUserMedia({
+ 'audio': true,
+ 'video': false,
+ });
+ for (final track in _localStream!.getTracks()) {
+ await pc.addTrack(track, _localStream!);
+ }
+
+ pc.onIceCandidate = _onLocalCandidate;
+ pc.onTrack = (event) {
+ if (event.streams.isNotEmpty) _remoteStream.add(event.streams.first);
+ };
+ pc.onConnectionState = (s) {
+ if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
+ s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
+ _end();
+ }
+ };
+
+ if (role == CallRole.caller) {
+ _setState(CallSessionState.ringing);
+ await _createAndSendOffer();
+ }
+ }
+
+ Future _createAndSendOffer() async {
+ final pc = _pc;
+ final peerId = _peerId;
+ if (pc == null || peerId == null) return;
+
+ final offer = await pc.createOffer({});
+ await pc.setLocalDescription(offer);
+ await _signaling?.transmitSdp(
+ participantId: peerId,
+ participantType: _peerType,
+ deviceIdx: _peerDeviceIdx,
+ type: offer.type!,
+ sdp: offer.sdp!,
+ );
+ }
+
+ Future _onTransmittedData(Map msg) async {
+ final pc = _pc;
+ if (pc == null) return;
+
+ final data = msg['data'];
+ if (data is! Map) return;
+
+ final sdp = data['sdp'];
+ if (sdp is Map) {
+ final type = sdp['type'] as String?;
+ final desc = sdp['sdp'] as String?;
+ if (type == null || desc == null) return;
+
+ await pc.setRemoteDescription(RTCSessionDescription(desc, type));
+
+ if (type == 'offer') {
+ // Сторона вызываемого: отвечаем answer.
+ final answer = await pc.createAnswer({});
+ await pc.setLocalDescription(answer);
+ final peerId = _peerId;
+ if (peerId != null) {
+ await _signaling?.transmitSdp(
+ participantId: peerId,
+ participantType: _peerType,
+ deviceIdx: _peerDeviceIdx,
+ type: answer.type!,
+ sdp: answer.sdp!,
+ );
+ }
+ if (_current == CallSessionState.connecting) {
+ _setState(CallSessionState.ringing);
+ }
+ }
+ return;
+ }
+
+ final candidate = data['candidate'];
+ if (candidate is Map) {
+ await pc.addCandidate(RTCIceCandidate(
+ candidate['candidate'] as String?,
+ candidate['sdpMid'] as String?,
+ candidate['sdpMLineIndex'] as int?,
+ ));
+ }
+ }
+
+ void _onLocalCandidate(RTCIceCandidate candidate) {
+ final peerId = _peerId;
+ if (peerId == null || candidate.candidate == null) return;
+ _signaling?.transmitCandidate(
+ participantId: peerId,
+ participantType: _peerType,
+ deviceIdx: _peerDeviceIdx,
+ candidate: candidate.candidate!,
+ sdpMid: candidate.sdpMid ?? '0',
+ sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
+ );
+ }
+
+ /// Принять входящий звонок (сторона вызываемого).
+ Future accept() async {
+ if (_accepted) return;
+ _accepted = true;
+ await _signaling?.acceptCall();
+ await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
+ _setState(CallSessionState.active);
+ }
+
+ /// DEBUG: отправить серверу сигнал `change-media-settings` с заданным
+ /// состоянием микрофона, НЕ трогая реальный аудиотрек.
+ Future sendAudioEnabledSignal(bool enabled) async {
+ await _signaling?.changeMediaSettings(isAudioEnabled: enabled);
+ }
+
+ Future setMuted(bool muted) async {
+ _muted = muted;
+ for (final track in _localStream?.getAudioTracks() ?? []) {
+ track.enabled = !muted;
+ }
+ await _signaling?.changeMediaSettings(isAudioEnabled: !muted);
+ }
+
+ Future hangup({String reason = 'HUNGUP'}) async {
+ try {
+ await _signaling?.hangup(reason: reason);
+ } catch (_) {}
+ _end();
+ }
+
+ bool _ended = false;
+ void _end() {
+ if (_ended) return;
+ _ended = true;
+ _setState(CallSessionState.ended);
+ _dispose();
+ }
+
+ Future _dispose() async {
+ for (final track in _localStream?.getTracks() ?? []) {
+ await track.stop();
+ }
+ await _localStream?.dispose();
+ await _pc?.close();
+ await _signaling?.close();
+ if (!_state.isClosed) await _state.close();
+ if (!_remoteStream.isClosed) await _remoteStream.close();
+ }
+
+ void _resolvePeer(Object? conversation) {
+ if (conversation is! Map) return;
+ final participants = conversation['participants'];
+ if (participants is! List) return;
+ for (final p in participants.whereType