Files
Qlyra/lib/core/calls/call_controller.dart
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

286 lines
7.8 KiB
Dart

import 'dart:async';
import '../../backend/api.dart';
import '../../backend/modules/calls.dart';
import '../protocol/opcode_map.dart';
import '../push/fkm_controller.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._();
static final CallController instance = CallController._();
Api? _api;
CallsModule? _calls;
StreamSubscription<Packet>? _pushSub;
final _incoming = StreamController<IncomingCall>.broadcast();
final _ended = StreamController<void>.broadcast();
final _canceled = StreamController<void>.broadcast();
bool appResumed = false;
Stream<IncomingCall> get incomingCalls => _incoming.stream;
Stream<void> get callEnded => _ended.stream;
Stream<void> get incomingCanceled => _canceled.stream;
CallSession? _active;
StreamSubscription<CallSessionState>? _activeSub;
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;
// Приложение свёрнуто — звонок показывает FKM отдельным уведомлением,
// приём оттуда вернётся через injectFromNative.
if (!appResumed) {
unawaited(FkmController.instance.showIncomingCall(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?,
),
);
}
void injectFromNative(Map<dynamic, dynamic> 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']?.toString(),
autoAccept: autoAccept,
),
);
}
void _emitIncoming(IncomingCall incoming) {
if (_active != null) return;
if (_pending?.conversationId == incoming.conversationId) return;
_pending = incoming;
_incoming.add(incoming);
}
void dismissIncoming() {
if (_pending == null) return;
_pending = null;
_canceled.add(null);
}
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,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final session = CallSession(ws2Config: config, role: CallRole.caller);
return _launch(session, session.start);
}
Future<CreatedCall> createConference() async {
if (_active != null) throw StateError('уже идёт звонок');
return _calls!.createConference();
}
Future<CallLinkPreview?> previewCallLink(String url) =>
_calls!.resolveCallLink(url);
Future<CallSession> joinByLink(String token, {bool isVideo = false}) async {
if (_active != null) throw StateError('уже идёт звонок');
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,
);
return _launch(session, session.start);
}
Future<CallSession> acceptIncoming(IncomingCall call) async {
_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,
);
return _launch(session, () async {
await session.start();
await session.accept();
}, caller: call.callerName);
}
Future<void> rejectIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.notifyEnded();
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
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();
Future<bool> sendMicSignal(bool enabled) async {
final session = _active;
if (session == null) return false;
await session.sendAudioEnabledSignal(enabled);
return true;
}
Future<CallSession> _launch(
CallSession session,
Future<void> 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);
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<void> _release(CallSession session) async {
if (!identical(_active, session)) return;
_active = null;
await _activeSub?.cancel();
_activeSub = null;
CallBridge.instance.notifyEnded();
_ended.add(null);
}
void dispose() {
_activeSub?.cancel();
_pushSub?.cancel();
_incoming.close();
_ended.close();
_canceled.close();
}
}