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
Native crypto / native-crypto (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
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
357 lines
10 KiB
Dart
357 lines
10 KiB
Dart
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<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;
|
|
|
|
String? _activeJoinLink;
|
|
String? get activeJoinLink => _active == null ? null : _activeJoinLink;
|
|
bool _starting = false;
|
|
|
|
IncomingCall? _pending;
|
|
IncomingCall? get pendingIncoming => _pending;
|
|
final _finishedIncoming = <String>{};
|
|
|
|
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<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'] ?? 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<CallSession> 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<CreatedCall> createConference() async {
|
|
if (isBusy) throw StateError('уже идёт звонок');
|
|
return _calls!.createConference();
|
|
}
|
|
|
|
Future<CallLinkPreview?> previewCallLink(String url) =>
|
|
_calls!.resolveCallLink(url);
|
|
|
|
Future<CallSession> 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<CallSession> _startExclusive(
|
|
Future<CallSession> Function() start,
|
|
) async {
|
|
if (isBusy) throw StateError('уже идёт звонок');
|
|
_starting = true;
|
|
try {
|
|
return await start();
|
|
} finally {
|
|
_starting = false;
|
|
}
|
|
}
|
|
|
|
Future<CallSession> 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<void> 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<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,
|
|
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<void> _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();
|
|
}
|
|
}
|