Merge pull request 'video_calls_fixes' (#1066) from misha/eatvio_app:video_calls_fixes into main

Reviewed-on: eatvio/eatvio_app#1066
This commit is contained in:
2026-10-05 11:56:40 +00:00
13 changed files with 1080 additions and 326 deletions

View File

@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
return
}
if (MainActivity.isInForeground) {
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
return
}
// Data-only call pushes are delivered only to this service.
// FlutterFirebaseMessagingService is removed, so the Dart listener does
// not see them. Show the incoming UI here for both foreground and background.
VideoCallPushBridge.showIncomingCall(this, message.data)
}

View File

@@ -264,7 +264,7 @@ object VideoCallPushBridge {
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")

View File

@@ -146,6 +146,35 @@ import flutter_foreground_task
)
}
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
/// so the next call in this chat is not rejected as a duplicate UUID.
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
guard let messageId, messageId > 0 else { return nil }
let pattern =
#"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
guard let match = regex.firstMatch(in: conversationUuid, range: range),
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
else { return nil }
let suffix = String(format: "%012llx", messageId)
guard suffix.count == 12 else { return nil }
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
}
private func voipMessageId(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber {
let parsed = number.uint64Value
return parsed > 0 ? parsed : nil
}
if let text = value as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
return parsed
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -369,13 +398,16 @@ import flutter_foreground_task
return
}
let nameCaller = (dict["nameCaller"] as? String)
let rawNameCaller = (dict["nameCaller"] as? String)
?? (dict["counterpartName"] as? String)
?? (dict["counterpart_name"] as? String)
?? (dict["caller_name"] as? String)
?? (dict["callerName"] as? String)
?? (dict["caller"] as? String)
?? ""
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Итвио"
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
let conversationUuid = (dict["conversation_uuid"] as? String)
?? (dict["conversationUuid"] as? String)
?? (dict["conversation_id"] as? String)
@@ -384,26 +416,27 @@ import flutter_foreground_task
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
?? (dict["handle"] as? String)
?? ""
let callKitIdSource = [
(key: "call_id", value: dict["call_id"] as? String),
(key: "id", value: dict["id"] as? String),
(key: "message_id", value: dict["message_id"] as? String),
]
.first { item in
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return false
}
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
}
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedMessageId = voipMessageId(dict["message_id"])
let id: String
let callKitIdSourceName: String
if let derived = derivedCallKitId(
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
) {
id = derived
callKitIdSourceName = "conversation_and_message"
} else {
id = UUID().uuidString
callKitIdSourceName = "generated"
}
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
?? true
var extra: [String: Any] = [:]
if !conversationUuid.isEmpty {
extra["conversationUuid"] = conversationUuid
if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = trimmedConversationUuid
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
@@ -435,9 +468,10 @@ import flutter_foreground_task
let data = flutter_callkit_incoming.Data(
id: id,
nameCaller: nameCaller,
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.duration = 45000
data.extra = extra as NSDictionary
NSLog(

View File

@@ -5,9 +5,14 @@ import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
@@ -18,13 +23,22 @@ class ChatsCubit extends Cubit<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
ChatsCubit({
required this.chatRepository,
required this.wsManager,
required this.profileRepository,
this.presentIncomingVideoCall,
this.dismissIncomingVideoCall,
}) : super(ChatsState()) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
message,
) {
_setNewLastMessage(message);
});
}
@@ -35,7 +49,9 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> connectToWebSocket() async {
try {
await newIncomingMessagesSubscription?.cancel();
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
event,
) {
_handleNewMessageViaSocket(event.data);
});
@@ -53,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
_query = query;
emit(state.copyWith(state: BaseState.loading));
try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit(
@@ -66,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
page++;
} catch (e, st) {
await reportFrom(e, st, this);
emit(
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
}
}
@@ -76,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
page = 1;
_query = query;
try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit(
@@ -90,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
emit(
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
}
}
@@ -100,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
try {
if (state.isLoadingMoreChats || lastPage == page) return;
emit(state.copyWith(isLoadingMoreChats: true));
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: _query,
page: page,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
emit(
state.copyWith(
@@ -140,7 +159,9 @@ class ChatsCubit extends Cubit<ChatsState> {
emit(
state.copyWith(
chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
chatsWithUnreadMessagesCount: isChatRead
? state.chatsWithUnreadMessagesCount - 1
: state.chatsWithUnreadMessagesCount,
),
);
}
@@ -162,7 +183,8 @@ class ChatsCubit extends Cubit<ChatsState> {
wasChanged = true;
// Идемпотентность: повторная доставка того же message.id (реконнект,
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
final isDuplicate = message.id != 0 && chatRoom.lastMessage?.id == message.id;
final isDuplicate =
message.id != 0 && chatRoom.lastMessage?.id == message.id;
// Собственное сообщение (второе устройство автора) обновляет
// lastMessage, но не создаёт непрочитанное (S3).
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
@@ -170,7 +192,9 @@ class ChatsCubit extends Cubit<ChatsState> {
}
return chatRoom.copyWith(
lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate ? chatRoom.unreadMessagesCount : chatRoom.unreadMessagesCount + 1,
unreadMessagesCount: isOwn || isDuplicate
? chatRoom.unreadMessagesCount
: chatRoom.unreadMessagesCount + 1,
);
}
return chatRoom;
@@ -182,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
emit(
state.copyWith(
chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
chatsWithUnreadMessagesCount: willIncrementUnreadChats
? state.chatsWithUnreadMessagesCount + 1
: state.chatsWithUnreadMessagesCount,
),
);
}
_presentVideoCallFromMessage(
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _presentVideoCallFromMessage(
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi());
return;
}
if (isOwn || kind != VideoCallChatSignalKind.started) return;
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
return;
}
final caller = _callerFromRooms(message, chatRooms);
final payload = VideoCallChatSignal.incomingPayload(
message: message,
callerName: caller?.name,
callerAvatarUrl: caller?.profilePhotoUrl,
);
if (payload == null) return;
unawaited(_showIncomingVideoCall(payload));
}
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
try {
await (presentIncomingVideoCall ??
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
} catch (e, st) {
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
}
}
Future<void> _dismissIncomingVideoCallUi() async {
try {
await (dismissIncomingVideoCall ??
VideoCallEndedPushHandler.dismissIncomingCallUi)();
} catch (e, st) {
log(
'ChatsCubit: failed to dismiss incoming video call: $e',
stackTrace: st,
);
}
}
ChatUser? _callerFromRooms(
ChatMessageModel message,
List<ChatRoom> chatRooms,
) {
for (final room in chatRooms) {
if (room.id != message.roomId) continue;
final from = room.users.fromChatUser;
final to = room.users.toChatUser;
if (from.id == message.authorId) return from;
if (to.id == message.authorId) return to;
}
return null;
}
void _setNewLastMessage(ChatMessageModel message) {
final chats = state.chatRooms.map((chat) {
if (chat.id == message.roomId) {

View File

@@ -932,7 +932,13 @@ class VideoCallManager {
_preparedWsUrl = tokenResponse.wsUrl;
_preparedToken = tokenResponse.token;
await room.prepareConnection(tokenResponse.wsUrl, tokenResponse.token);
// Warmup must not keep the enter button disabled. Joining the room
// connects with the token even if prepareConnection hangs or fails.
try {
await room
.prepareConnection(tokenResponse.wsUrl, tokenResponse.token)
.timeout(const Duration(seconds: 5));
} catch (_) {}
if (_liveKitDisposed) return;
_emitSnapshot(

View File

@@ -0,0 +1,85 @@
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
enum VideoCallChatSignalKind { none, started, ended }
/// Detects a video-call system message.
///
/// The realtime `ChatMessageUpdated` payload does not include `type`, so an
/// open app has to recognize the system text. Messages that already have
/// another type are ignored.
abstract final class VideoCallChatSignal {
static final RegExp _startedRu = RegExp(
r'начал\(а\) видеозвонок\.?\s*$',
caseSensitive: false,
);
static final RegExp _startedEn = RegExp(
r'started a video call\.?\s*$',
caseSensitive: false,
);
static final RegExp _endedRu = RegExp(
r'завершил\(а\) видеозвонок\.?\s*$',
caseSensitive: false,
);
static final RegExp _endedEn = RegExp(
r'ended a video call\.?\s*$',
caseSensitive: false,
);
static final RegExp _callerRu = RegExp(
r'^(.*) начал\(а\) видеозвонок\.?\s*$',
caseSensitive: false,
);
static final RegExp _callerEn = RegExp(
r'^(.*) started a video call\.?\s*$',
caseSensitive: false,
);
static VideoCallChatSignalKind kindOf(ChatMessageModel message) {
if (message.type == ChatMessageType.videoCallEnded) {
return VideoCallChatSignalKind.ended;
}
if (message.type == ChatMessageType.videoCallStarted) {
return VideoCallChatSignalKind.started;
}
if (message.type != null) return VideoCallChatSignalKind.none;
final text = message.data.trim();
if (_endedRu.hasMatch(text) || _endedEn.hasMatch(text)) {
return VideoCallChatSignalKind.ended;
}
if (_startedRu.hasMatch(text) || _startedEn.hasMatch(text)) {
return VideoCallChatSignalKind.started;
}
return VideoCallChatSignalKind.none;
}
static VideoCallPushPayload? incomingPayload({
required ChatMessageModel message,
String? callerName,
String? callerAvatarUrl,
}) {
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
final conversationUuid = message.roomId?.trim();
if (conversationUuid == null || conversationUuid.isEmpty) return null;
final resolvedName = callerName?.trim();
final avatar = callerAvatarUrl?.trim();
return VideoCallPushPayload(
conversationUuid: conversationUuid,
counterpartName: resolvedName != null && resolvedName.isNotEmpty
? resolvedName
: callerNameFromBody(message.data),
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
messageId: message.id > 0 ? message.id : null,
);
}
static String? callerNameFromBody(String body) {
final text = body.trim();
for (final pattern in [_callerRu, _callerEn]) {
final name = pattern.firstMatch(text)?.group(1)?.trim();
if (name != null && name.isNotEmpty) return name;
}
return null;
}
}

View File

@@ -1,3 +1,6 @@
import 'dart:io';
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -6,18 +9,25 @@ abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
final callerName =
payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final callId = Platform.isIOS
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
) ??
payload.conversationUuid
: payload.conversationUuid;
final params = CallKitParams(
id: payload.conversationUuid,
id: callId,
nameCaller: callerName,
appName: 'Итвио',
avatar: payload.counterpartAvatarUrl,
handle: payload.conversationUuid,
type: 0,
type: 1,
duration: 45000,
extra: <String, dynamic>{
'conversationUuid': payload.conversationUuid,

View File

@@ -0,0 +1,23 @@
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
///
/// The conversation uuid stays in the call payload for navigation. The CallKit
/// id is derived from that uuid plus the chat message id, so one ring is one
/// call and the next ring in the same chat still appears. Android notifications
/// keep the conversation uuid: the same id updates one incoming notification.
abstract final class VideoCallKitId {
static final RegExp _uuidPrefix = RegExp(
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
);
static String? iosUuid({
required String conversationUuid,
required int? messageId,
}) {
if (messageId == null || messageId <= 0) return null;
final prefix = _uuidPrefix.firstMatch(conversationUuid.trim())?.group(1);
if (prefix == null) return null;
final suffix = messageId.toRadixString(16).padLeft(12, '0');
if (suffix.length != 12) return null;
return '$prefix$suffix'.toLowerCase();
}
}

View File

@@ -5,6 +5,7 @@ class VideoCallPushPayload {
this.counterpartAvatarUrl,
this.localParticipantName,
this.counterpartIsConsultant,
this.messageId,
});
final String conversationUuid;
@@ -12,6 +13,7 @@ class VideoCallPushPayload {
final String? counterpartAvatarUrl;
final String? localParticipantName;
final bool? counterpartIsConsultant;
final int? messageId;
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
if (!isIncomingVideoCall(data)) return null;
@@ -49,6 +51,7 @@ class VideoCallPushPayload {
'is_consultant',
'isConsultant',
]),
messageId: _messageId(data),
);
}
@@ -132,7 +135,8 @@ class VideoCallPushPayload {
final uri = Uri.tryParse(actionUrl);
if (uri == null) return null;
final fromQuery = uri.queryParameters['uuid'] ??
final fromQuery =
uri.queryParameters['uuid'] ??
uri.queryParameters['conversationUuid'] ??
uri.queryParameters['conversation_uuid'];
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
@@ -167,14 +171,22 @@ class VideoCallPushPayload {
return null;
}
static int? _messageId(Map<String, dynamic> data) {
final value = data['message_id'] ?? data['messageId'];
if (value is int) return value > 0 ? value : null;
if (value is String) {
final parsed = int.tryParse(value.trim());
if (parsed != null && parsed > 0) return parsed;
}
return null;
}
static bool _isTruthy(Object? value) {
if (value == null) return false;
if (value is bool) return value;
if (value is num) return value != 0;
final normalized = value.toString().trim().toLowerCase();
return normalized == '1' ||
normalized == 'true' ||
normalized == 'yes';
return normalized == '1' || normalized == 'true' || normalized == 'yes';
}
static String? _resolveAvatarUrl(String? raw) {

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.46+135
version: 1.3.47+136
publish_to: 'none'
environment:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
ChatMessageModel message({
String data = '',
String? roomId = 'room-1',
ChatMessageType? type,
}) {
return ChatMessageModel(
id: 1,
data: data,
mediaData: null,
authorId: 7,
updatedAt: DateTime.utc(2026, 1, 1),
isRead: false,
isCurrentUserAuthor: false,
roomId: roomId,
type: type,
);
}
group('VideoCallChatSignal', () {
test('recognizes a realtime video call start without type', () {
final payload = VideoCallChatSignal.incomingPayload(
message: message(data: 'Анна начал(а) видеозвонок.'),
);
expect(payload, isNotNull);
expect(payload!.conversationUuid, 'room-1');
expect(payload.counterpartName, 'Анна');
expect(payload.messageId, 1);
});
test('recognizes an english realtime video call start', () {
expect(
VideoCallChatSignal.kindOf(message(data: 'Anna started a video call.')),
VideoCallChatSignalKind.started,
);
});
test('recognizes a realtime video call end', () {
expect(
VideoCallChatSignal.kindOf(
message(data: 'Анна завершил(а) видеозвонок.'),
),
VideoCallChatSignalKind.ended,
);
});
test('uses an explicit message type when the broadcast includes it', () {
expect(
VideoCallChatSignal.kindOf(
message(data: 'ignored', type: ChatMessageType.videoCallStarted),
),
VideoCallChatSignalKind.started,
);
});
test('does not treat an ordinary chat message as a call', () {
expect(
VideoCallChatSignal.kindOf(
message(
data: 'я начал видеозвонок вручную',
type: ChatMessageType.text,
),
),
VideoCallChatSignalKind.none,
);
expect(
VideoCallChatSignal.incomingPayload(message: message(data: 'привет')),
isNull,
);
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -58,6 +59,23 @@ void main() {
expect(payload, isNotNull);
});
test('parses message id for a stable CallKit uuid', () {
final payload = VideoCallPushPayload.tryParse({
'type': 'video_call_started',
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
'message_id': '42',
});
expect(payload?.messageId, 42);
expect(
VideoCallKitId.iosUuid(
conversationUuid: payload!.conversationUuid,
messageId: payload.messageId,
),
'550e8400-e29b-41d4-a716-00000000002a',
);
});
test('ignores regular chat push', () {
expect(
VideoCallPushPayload.tryParse({