Files
Qlyra/lib/backend/modules/calls.dart
T

425 lines
11 KiB
Dart

import 'dart:convert';
import 'contacts.dart';
import '../api.dart';
import '../../core/calls/ws2_signaling.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/ids.dart';
import '../../core/utils/logger.dart';
enum CallStatus { missed, canceled, outgoing, incoming }
class OutgoingCallParams {
final String conversationId;
final String endpoint;
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 CallLinkPreview {
final String? conferenceId;
final String? callName;
final int participantsCount;
final bool isVideo;
const CallLinkPreview({
this.conferenceId,
this.callName,
this.participantsCount = 0,
this.isVideo = false,
});
}
class CallLogEntry {
final String id;
final int accountId;
final int peerId;
final String name;
final String? avatarUrl;
final CallStatus status;
final int time;
final int count;
final bool isGroup;
const CallLogEntry({
required this.id,
required this.accountId,
required this.peerId,
required this.name,
this.avatarUrl,
required this.status,
required this.time,
this.count = 1,
this.isGroup = false,
});
}
class _CallerEndpointMissingException implements Exception {
final String message;
const _CallerEndpointMissingException(this.message);
@override
String toString() => 'Exception: $message';
}
typedef _CallerEndpoint = ({String endpoint, int callsUserId, int? external});
class CallsModule {
final Api _api;
CallsModule(this._api);
_CallerEndpoint _parseCallerEndpoint(
Map payload,
String key, {
required String context,
}) {
final raw = payload[key];
final parsed = raw is String
? jsonDecode(raw) as Map<dynamic, dynamic>
: const <dynamic, dynamic>{};
final endpoint = parsed['endpoint'] as String?;
if (endpoint == null) {
throw _CallerEndpointMissingException('$context: no endpoint');
}
final id = parsed['id'];
final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0;
final external = id is Map ? int.tryParse('${id['external']}') : null;
return (endpoint: endpoint, callsUserId: callsUserId, external: external);
}
Future<OutgoingCallParams> initiateCall(
int calleeId, {
bool isVideo = false,
}) async {
final conversationId = uuidV4();
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
'conversationId': conversationId,
'calleeIds': [calleeId],
'internalParams': _internalParams(),
'isVideo': isVideo,
});
if (payload == null) {
throw Exception('initiateCall: bad response');
}
final parsed = _parseCallerEndpoint(
payload,
'internalCallerParams',
context: 'initiateCall',
);
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? conversationId,
endpoint: parsed.endpoint,
callsUserId: parsed.callsUserId,
peerExternalId: parsed.external ?? calleeId,
isVideo: isVideo,
);
}
Future<OutgoingCallParams> startGroupCall({bool isVideo = false}) async {
final conversationId = uuidV4();
logger.i('[call] VIDEO_CHAT_START_ACTIVE group conv=$conversationId');
final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, {
'conversationId': conversationId,
'internalParams': _internalParams(),
'isVideo': isVideo,
});
logger.i('[call] VIDEO_CHAT_START_ACTIVE keys=${payload?.keys.toList()}');
if (payload == null) {
throw Exception('startGroupCall: bad response');
}
final parsed = _parseCallerEndpoint(
payload,
'internalCallerParams',
context: 'startGroupCall',
);
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? conversationId,
endpoint: parsed.endpoint,
callsUserId: parsed.callsUserId,
peerExternalId: 0,
isVideo: isVideo,
);
}
Future<String?> createJoinLink(String conversationId) async {
if (conversationId.isEmpty) return null;
final payload = await _api.sendRequestMap(Opcode.videoChatCreateJoinLink, {
'conversationId': conversationId,
});
final link = payload?['joinLink'];
return link is String && link.isNotEmpty ? link : null;
}
String _internalParams() => jsonEncode({
'platform': 'ANDROID',
'sdkVersion': '0.2.1.3',
'clientAppKey': 'CGPGAGLGDIHBABABA',
'deviceId': _api.deviceId ?? '',
'protocolVersion': 5,
'onlyAdminCanRecord': false,
'isWaitForAdminEnabled': false,
'hexCapability': Ws2Config.defaultCapabilities,
});
Future<CallLinkPreview?> resolveCallLink(String url) async {
final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url});
if (payload == null) return null;
final vc = payload['videoConference'];
if (vc is! Map) return null;
return CallLinkPreview(
conferenceId: vc['conferenceId']?.toString(),
callName: (vc['callName'] as String?)?.trim(),
participantsCount: (vc['participantsCount'] as int?) ?? 0,
isVideo: vc['callType'] == 'VIDEO',
);
}
Future<OutgoingCallParams> joinByLink(
String token, {
bool isVideo = false,
}) async {
logger.i('[call] VIDEO_CHAT_JOIN link=$token isVideo=$isVideo');
final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, {
'joinLink': token,
'internalParams': _internalParams(),
'isVideo': isVideo,
});
logger.i('[call] VIDEO_CHAT_JOIN keys=${payload?.keys.toList()}');
if (payload == null) {
throw Exception('joinByLink: bad response');
}
final parsed = _parseCallerEndpoint(
payload,
'internalParams',
context: 'joinByLink',
);
return OutgoingCallParams(
conversationId: (payload['conversationId'] as String?) ?? '',
endpoint: parsed.endpoint,
callsUserId: parsed.callsUserId,
peerExternalId: 0,
isVideo: isVideo,
);
}
Future<List<CallLogEntry>> fetchHistory(
int accountId,
int currentUserId,
) async {
final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {});
if (payload == null) return [];
return parseHistoryPayload(
payload,
accountId,
currentUserId,
resolver: resolveContacts,
);
}
Future<Map<int, Map<String, dynamic>>> resolveContacts(List<int> ids) async {
if (ids.isEmpty) return const {};
final out = <int, Map<String, dynamic>>{};
try {
final resp = await _api.sendRequest(Opcode.contactInfo, {
'contactIds': ids,
});
final data = resp.payload;
final contacts = data is Map ? data['contacts'] : null;
if (contacts is List) {
for (final c in contacts) {
if (c is Map) {
final id = c['id'];
if (id is int) out[id] = Map<String, dynamic>.from(c);
}
}
}
} catch (e) {
logger.w('resolveContacts: $e');
}
return out;
}
Future<bool> deleteHistory(List<int> historyIds) async {
if (historyIds.isEmpty) return true;
return _api.sendRequestOk(Opcode.videoChatDeleteHistory, {
'historyIds': historyIds,
});
}
static Future<List<CallLogEntry>> parseHistoryPayload(
Map<dynamic, dynamic> payload,
int accountId,
int currentUserId, {
Future<Map<int, Map<String, dynamic>>> Function(List<int> ids)? resolver,
}) async {
final history = payload['history'];
if (history is! List || history.isEmpty) return [];
final recentContacts = await ContactsModule.getContacts(accountId);
final contactsMap = {for (final c in recentContacts) c.id: c};
final parsed = <({int peerId, CallStatus status, int time, String id})>[];
for (final item in history.whereType<Map>()) {
final msg = item['message'];
if (msg is! Map) continue;
final attaches = msg['attaches'];
if (attaches is! List || attaches.isEmpty) continue;
final callAttach = attaches.firstWhere(
(a) => a is Map && a['_type'] == 'CALL',
orElse: () => null,
);
if (callAttach == null) continue;
final senderId = (msg['sender'] as int?) ?? 0;
final isOutgoing = senderId == currentUserId;
int peerId = 0;
if (isOutgoing) {
final contactIds = callAttach['contactIds'];
if (contactIds is List && contactIds.isNotEmpty) {
peerId = (contactIds.first as int?) ?? 0;
}
} else {
peerId = senderId;
}
parsed.add((
peerId: peerId,
status: _parseCallStatus(callAttach, isOutgoing),
time: (msg['time'] as int?) ?? 0,
id:
msg['id']?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString(),
));
}
bool localResolved(int id) {
final c = contactsMap[id];
return c != null && c.firstName.isNotEmpty;
}
final unresolvedIds = parsed
.map((e) => e.peerId)
.where((id) => id != 0 && !localResolved(id))
.toSet()
.toList();
var fetched = const <int, Map<String, dynamic>>{};
if (unresolvedIds.isNotEmpty && resolver != null) {
fetched = await resolver(unresolvedIds);
}
final List<CallLogEntry> extractedCalls = [];
for (final e in parsed) {
final contact = contactsMap[e.peerId];
String name;
String? avatarUrl;
bool isGroup = false;
if (contact != null && contact.firstName.isNotEmpty) {
name = '${contact.firstName} ${contact.lastName ?? ''}'.trim();
avatarUrl = contact.baseUrl;
} else {
final info = fetched[e.peerId];
final resolved = _nameFromInfo(info);
if (resolved != null) {
name = resolved;
avatarUrl = (info?['baseUrl'] as String?) ?? contact?.baseUrl;
} else {
name = 'Групповой звонок';
isGroup = true;
}
}
extractedCalls.add(
CallLogEntry(
id: e.id,
accountId: accountId,
peerId: e.peerId,
name: name,
avatarUrl: avatarUrl,
status: e.status,
time: e.time,
isGroup: isGroup,
),
);
}
return extractedCalls;
}
static String? _nameFromInfo(Map<String, dynamic>? info) {
if (info == null) return null;
final names = info['names'];
if (names is List && names.isNotEmpty) {
final n = names.first;
if (n is Map) {
final full = n['name']?.toString();
if (full != null && full.isNotEmpty) return full;
final first = n['firstName']?.toString() ?? '';
final last = n['lastName']?.toString() ?? '';
final combined = '$first $last'.trim();
if (combined.isNotEmpty) return combined;
}
}
return null;
}
static CallStatus _parseCallStatus(
Map<dynamic, dynamic> callAttach,
bool isOutgoing,
) {
final hangupType = callAttach['hangupType'];
final duration = (callAttach['duration'] as int?) ?? 0;
if (isOutgoing) {
if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled;
return CallStatus.outgoing;
} else {
if (hangupType == 'CANCELED' ||
hangupType == 'REJECTED' ||
hangupType == 'MISSED' ||
duration == 0) {
return CallStatus.missed;
}
return CallStatus.incoming;
}
}
}