import 'dart:async'; import 'package:flutter/foundation.dart' show visibleForTesting; import '../../backend/api.dart'; import '../../backend/modules/calls.dart'; import '../protocol/opcode_map.dart'; import '../protocol/packet.dart'; import '../utils/parse.dart'; import 'call_bridge.dart'; import 'call_session.dart'; import 'conversation_params.dart'; import 'ws2_signaling.dart'; class IncomingCall { final String conversationId; final int callerId; final bool isVideo; final ConversationParams params; final String? country; final bool? isContact; final String? callerName; final bool autoAccept; const IncomingCall({ required this.conversationId, required this.callerId, required this.isVideo, required this.params, this.country, this.isContact, this.callerName, this.autoAccept = false, }); } class CallController { CallController._(); @visibleForTesting CallController.forTesting(); static final CallController instance = CallController._(); Api? _api; CallsModule? _calls; StreamSubscription? _pushSub; final _incoming = StreamController.broadcast(); final _ended = StreamController.broadcast(); final _canceled = StreamController.broadcast(); bool appResumed = false; Stream get incomingCalls => _incoming.stream; Stream get callEnded => _ended.stream; Stream get incomingCanceled => _canceled.stream; CallSession? _active; StreamSubscription? _activeSub; CallSession? get activeSession => _active; String? _activeJoinLink; String? get activeJoinLink => _active == null ? null : _activeJoinLink; bool _starting = false; IncomingCall? _pending; IncomingCall? get pendingIncoming => _pending; final _finishedIncoming = {}; bool get isBusy => _active != null || _starting; 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 id = payload['conversationId']?.toString(); if (payload['event'] == 'ended') { if (id != null) cancelConversation(id); return; } if (payload['event'] == 'history') { _ended.add(null); 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; if (_finishedIncoming.contains(conversationId)) return; final expiresAt = parseIntOrNull(payload['expiresAt']); if (expiresAt != null && expiresAt <= DateTime.now().millisecondsSinceEpoch) { return; } // The native call screen works while Flutter is in the background. if (!appResumed) { unawaited(CallBridge.instance.showIncoming(payload)); return; } final params = ConversationParams.decode(vcp); if (params == null) return; _emitIncoming( IncomingCall( conversationId: conversationId, callerId: callerId, isVideo: payload['type'] == 'VIDEO' || params.isVideo, params: params, country: payload['country'] as String?, isContact: payload['isContact'] as bool?, callerName: payload['userName']?.toString(), ), ); } void injectFromNative(Map data, {bool autoAccept = false}) { final vcp = data['vcp']?.toString(); if (vcp == null || vcp.isEmpty) return; final params = ConversationParams.decode(vcp); if (params == null) return; final conversationId = (data['conversationId'] ?? data['vcId'])?.toString(); if (conversationId == null || conversationId.isEmpty) return; final callerId = parseIntOrNull(data['callerId'] ?? data['suid']); if (callerId == null) return; final type = (data['type'] ?? data['callType'])?.toString(); final iv = data['iv']; final isVideo = params.isVideo || type == 'VIDEO' || iv == true || iv == 'true'; _emitIncoming( IncomingCall( conversationId: conversationId, callerId: callerId, isVideo: isVideo, params: params, country: data['country']?.toString(), isContact: data['isContact'] is bool ? data['isContact'] as bool : null, callerName: (data['userName'] ?? data['senderName'] ?? data['title']) ?.toString(), autoAccept: autoAccept, ), ); } void _emitIncoming(IncomingCall incoming) { if (_finishedIncoming.contains(incoming.conversationId)) return; if (isBusy) return; if (_pending?.conversationId == incoming.conversationId) return; _pending = incoming; _incoming.add(incoming); } void dismissIncoming() { if (_pending == null) return; _pending = null; _canceled.add(null); } void cancelConversation(String conversationId) { _finishedIncoming.add(conversationId); if (_finishedIncoming.length > 128) { _finishedIncoming.remove(_finishedIncoming.first); } unawaited(CallBridge.instance.cancelConversation(conversationId)); if (_pending?.conversationId == conversationId) dismissIncoming(); if ((_active?.info.conversationId ?? _active?.ws2Config.uri.queryParameters['conversationId']) == conversationId) { unawaited(endActive()); } _ended.add(null); } Future startOutgoing(int calleeId, {bool isVideo = false}) => _startExclusive(() async { final out = await _calls!.initiateCall(calleeId, isVideo: isVideo); final config = Ws2Config.fromEndpoint( out.endpoint, userId: out.callsUserId, device: _api?.callsDevice, osVersion: _api?.callsOsVersion, ); final session = CallSession( ws2Config: config, role: CallRole.caller, endOnServer: (reason) => _calls!.endCall(out.conversationId, reason: reason), ); return _launch(session, session.start); }); // #***! конференция со ссылкой Future createConference() async { if (isBusy) throw StateError('уже идёт звонок'); return _calls!.createConference(); } Future previewCallLink(String url) => _calls!.resolveCallLink(url); Future joinByLink(String token, {bool isVideo = false}) => _startExclusive(() async { final params = await _calls!.joinByLink(token, isVideo: isVideo); final config = Ws2Config.fromEndpoint( params.endpoint, userId: params.callsUserId, device: _api?.callsDevice, osVersion: _api?.callsOsVersion, ); final session = CallSession( ws2Config: config, role: CallRole.joiner, isGroup: true, ); _activeJoinLink = token; return _launch(session, session.start); }); Future _startExclusive( Future Function() start, ) async { if (isBusy) throw StateError('уже идёт звонок'); _starting = true; try { return await start(); } finally { _starting = false; } } Future acceptIncoming(IncomingCall call) async { if (_finishedIncoming.contains(call.conversationId) || isBusy) { throw StateError('Звонок уже завершён или занят'); } _pending = null; CallBridge.instance.cancelIncoming(); final config = Ws2Config.fromVcp( call.params, conversationId: call.conversationId, device: _api?.callsDevice, osVersion: _api?.callsOsVersion, ); final session = CallSession( ws2Config: config, params: call.params, role: CallRole.callee, endOnServer: (reason) => _calls!.endCall(call.conversationId, reason: reason), ); return _launch(session, () async { await session.start(); await session.accept(); }, caller: call.callerName); } Future rejectIncoming(IncomingCall call) async { _pending = null; CallBridge.instance.notifyEnded(conversationId: call.conversationId); final config = Ws2Config.fromVcp( call.params, conversationId: call.conversationId, device: _api?.callsDevice, osVersion: _api?.callsOsVersion, ); final signaling = Ws2Signaling(config); final endOnServer = _calls!.endCall( call.conversationId, reason: 'REJECTED', ); try { await signaling.connect(); await signaling.hangup(reason: 'REJECTED'); } catch (_) { } finally { await signaling.close(); await endOnServer; } } Future endActive() => _active?.hangup() ?? Future.value(); Future sendMicSignal(bool enabled) async { final session = _active; if (session == null) return false; await session.sendAudioEnabledSignal(enabled); return true; } Future _launch( CallSession session, Future Function() open, { String? caller, }) async { _bind(session); try { await open(); } catch (_) { await _release(session); try { await session.hangup(); } catch (_) {} rethrow; } CallBridge.instance.notifyAccepted( caller: caller, conversationId: session.ws2Config.uri.queryParameters['conversationId'], ); return session; } void _bind(CallSession session) { unawaited(_activeSub?.cancel()); _active = session; _activeSub = session.stateStream.listen((state) { if (state != CallSessionState.ended) return; unawaited(_release(session)); }); } Future _release(CallSession session) async { if (!identical(_active, session)) return; _active = null; _activeJoinLink = null; await _activeSub?.cancel(); _activeSub = null; CallBridge.instance.notifyEnded( conversationId: session.ws2Config.uri.queryParameters['conversationId'], ); _ended.add(null); } void dispose() { _activeSub?.cancel(); _pushSub?.cancel(); _incoming.close(); _ended.close(); _canceled.close(); } }