feat(video_calls): incommig call handling fix for ios and anroid

This commit is contained in:
misha
2026-10-05 13:12:55 +03:00
parent 2dce28b196
commit df634fedd8
12 changed files with 1079 additions and 325 deletions

View File

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

View File

@@ -264,7 +264,7 @@ object VideoCallPushBridge {
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") 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? { private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard guard
let actionUrl = actionUrl, let actionUrl = actionUrl,
@@ -369,13 +398,16 @@ import flutter_foreground_task
return return
} }
let nameCaller = (dict["nameCaller"] as? String) let rawNameCaller = (dict["nameCaller"] as? String)
?? (dict["counterpartName"] as? String) ?? (dict["counterpartName"] as? String)
?? (dict["counterpart_name"] as? String) ?? (dict["counterpart_name"] as? String)
?? (dict["caller_name"] as? String) ?? (dict["caller_name"] as? String)
?? (dict["callerName"] as? String) ?? (dict["callerName"] as? String)
?? (dict["caller"] as? String) ?? (dict["caller"] as? String)
?? "" ?? ""
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Итвио"
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
let conversationUuid = (dict["conversation_uuid"] as? String) let conversationUuid = (dict["conversation_uuid"] as? String)
?? (dict["conversationUuid"] as? String) ?? (dict["conversationUuid"] as? String)
?? (dict["conversation_id"] as? String) ?? (dict["conversation_id"] as? String)
@@ -384,26 +416,27 @@ import flutter_foreground_task
?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
?? (dict["handle"] as? String) ?? (dict["handle"] as? String)
?? "" ?? ""
let callKitIdSource = [ let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
(key: "call_id", value: dict["call_id"] as? String), let parsedMessageId = voipMessageId(dict["message_id"])
(key: "id", value: dict["id"] as? String), let id: String
(key: "message_id", value: dict["message_id"] as? String), let callKitIdSourceName: String
] if let derived = derivedCallKitId(
.first { item in conversationUuid: trimmedConversationUuid,
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else { messageId: parsedMessageId
return false ) {
} id = derived
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil callKitIdSourceName = "conversation_and_message"
} } else {
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString id = UUID().uuidString
let callKitIdSourceName = callKitIdSource?.key ?? "generated" callKitIdSourceName = "generated"
}
let isVideo = (dict["isVideo"] as? Bool) let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool) ?? (dict["is_video"] as? Bool)
?? true ?? true
var extra: [String: Any] = [:] var extra: [String: Any] = [:]
if !conversationUuid.isEmpty { if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = conversationUuid extra["conversationUuid"] = trimmedConversationUuid
} }
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String ?? dict["avatarUrl"] as? String
@@ -435,9 +468,10 @@ import flutter_foreground_task
let data = flutter_callkit_incoming.Data( let data = flutter_callkit_incoming.Data(
id: id, id: id,
nameCaller: nameCaller, nameCaller: nameCaller,
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0 type: isVideo ? 1 : 0
) )
data.duration = 45000
data.extra = extra as NSDictionary data.extra = extra as NSDictionary
NSLog( NSLog(

View File

@@ -5,9 +5,14 @@ import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart'; import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.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_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/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.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/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:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
@@ -18,13 +23,22 @@ class ChatsCubit extends Cubit<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription; StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription; StreamSubscription<void>? reconnectedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
ChatsCubit({ ChatsCubit({
required this.chatRepository, required this.chatRepository,
required this.wsManager, required this.wsManager,
required this.profileRepository, required this.profileRepository,
this.presentIncomingVideoCall,
this.dismissIncomingVideoCall,
}) : super(ChatsState()) { }) : super(ChatsState()) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) { newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
message,
) {
_setNewLastMessage(message); _setNewLastMessage(message);
}); });
} }
@@ -35,7 +49,9 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> connectToWebSocket() async { Future<void> connectToWebSocket() async {
try { try {
await newIncomingMessagesSubscription?.cancel(); await newIncomingMessagesSubscription?.cancel();
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) { newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
event,
) {
_handleNewMessageViaSocket(event.data); _handleNewMessageViaSocket(event.data);
}); });
@@ -53,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
_query = query; _query = query;
emit(state.copyWith(state: BaseState.loading)); emit(state.copyWith(state: BaseState.loading));
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -66,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
page++; page++;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -76,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
page = 1; page = 1;
_query = query; _query = query;
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -90,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -100,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
try { try {
if (state.isLoadingMoreChats || lastPage == page) return; if (state.isLoadingMoreChats || lastPage == page) return;
emit(state.copyWith(isLoadingMoreChats: true)); 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; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
emit( emit(
state.copyWith( state.copyWith(
@@ -140,7 +159,9 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, 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; wasChanged = true;
// Идемпотентность: повторная доставка того же message.id (реконнект, // Идемпотентность: повторная доставка того же message.id (реконнект,
// доставка по нескольким каналам) не начисляет непрочитанное дважды. // доставка по нескольким каналам) не начисляет непрочитанное дважды.
final isDuplicate = message.id != 0 && chatRoom.lastMessage?.id == message.id; final isDuplicate =
message.id != 0 && chatRoom.lastMessage?.id == message.id;
// Собственное сообщение (второе устройство автора) обновляет // Собственное сообщение (второе устройство автора) обновляет
// lastMessage, но не создаёт непрочитанное (S3). // lastMessage, но не создаёт непрочитанное (S3).
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) { if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
@@ -170,7 +192,9 @@ class ChatsCubit extends Cubit<ChatsState> {
} }
return chatRoom.copyWith( return chatRoom.copyWith(
lastMessage: message, lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate ? chatRoom.unreadMessagesCount : chatRoom.unreadMessagesCount + 1, unreadMessagesCount: isOwn || isDuplicate
? chatRoom.unreadMessagesCount
: chatRoom.unreadMessagesCount + 1,
); );
} }
return chatRoom; return chatRoom;
@@ -182,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, 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) { } catch (e, st) {
await reportFrom(e, st, this); 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) { void _setNewLastMessage(ChatMessageModel message) {
final chats = state.chatRooms.map((chat) { final chats = state.chatRooms.map((chat) {
if (chat.id == message.roomId) { if (chat.id == message.roomId) {

View File

@@ -932,7 +932,13 @@ class VideoCallManager {
_preparedWsUrl = tokenResponse.wsUrl; _preparedWsUrl = tokenResponse.wsUrl;
_preparedToken = tokenResponse.token; _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; if (_liveKitDisposed) return;
_emitSnapshot( _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:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -6,18 +9,25 @@ abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5'; static const String _androidAccentColor = '#2787F5';
static Future<void> showIncomingCall(VideoCallPushPayload payload) async { static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
final callerName = final callerName = payload.counterpartName?.trim().isNotEmpty == true
payload.counterpartName?.trim().isNotEmpty == true ? payload.counterpartName!.trim()
? payload.counterpartName!.trim() : 'Итвио';
: 'Итвио';
final callId = Platform.isIOS
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
) ??
payload.conversationUuid
: payload.conversationUuid;
final params = CallKitParams( final params = CallKitParams(
id: payload.conversationUuid, id: callId,
nameCaller: callerName, nameCaller: callerName,
appName: 'Итвио', appName: 'Итвио',
avatar: payload.counterpartAvatarUrl, avatar: payload.counterpartAvatarUrl,
handle: payload.conversationUuid, handle: payload.conversationUuid,
type: 0, type: 1,
duration: 45000, duration: 45000,
extra: <String, dynamic>{ extra: <String, dynamic>{
'conversationUuid': payload.conversationUuid, '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.counterpartAvatarUrl,
this.localParticipantName, this.localParticipantName,
this.counterpartIsConsultant, this.counterpartIsConsultant,
this.messageId,
}); });
final String conversationUuid; final String conversationUuid;
@@ -12,6 +13,7 @@ class VideoCallPushPayload {
final String? counterpartAvatarUrl; final String? counterpartAvatarUrl;
final String? localParticipantName; final String? localParticipantName;
final bool? counterpartIsConsultant; final bool? counterpartIsConsultant;
final int? messageId;
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) { static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
if (!isIncomingVideoCall(data)) return null; if (!isIncomingVideoCall(data)) return null;
@@ -49,6 +51,7 @@ class VideoCallPushPayload {
'is_consultant', 'is_consultant',
'isConsultant', 'isConsultant',
]), ]),
messageId: _messageId(data),
); );
} }
@@ -132,7 +135,8 @@ class VideoCallPushPayload {
final uri = Uri.tryParse(actionUrl); final uri = Uri.tryParse(actionUrl);
if (uri == null) return null; if (uri == null) return null;
final fromQuery = uri.queryParameters['uuid'] ?? final fromQuery =
uri.queryParameters['uuid'] ??
uri.queryParameters['conversationUuid'] ?? uri.queryParameters['conversationUuid'] ??
uri.queryParameters['conversation_uuid']; uri.queryParameters['conversation_uuid'];
if (fromQuery != null && fromQuery.trim().isNotEmpty) { if (fromQuery != null && fromQuery.trim().isNotEmpty) {
@@ -167,14 +171,22 @@ class VideoCallPushPayload {
return null; 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) { static bool _isTruthy(Object? value) {
if (value == null) return false; if (value == null) return false;
if (value is bool) return value; if (value is bool) return value;
if (value is num) return value != 0; if (value is num) return value != 0;
final normalized = value.toString().trim().toLowerCase(); final normalized = value.toString().trim().toLowerCase();
return normalized == '1' || return normalized == '1' || normalized == 'true' || normalized == 'yes';
normalized == 'true' ||
normalized == 'yes';
} }
static String? _resolveAvatarUrl(String? raw) { static String? _resolveAvatarUrl(String? raw) {

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_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:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -58,6 +59,23 @@ void main() {
expect(payload, isNotNull); 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', () { test('ignores regular chat push', () {
expect( expect(
VideoCallPushPayload.tryParse({ VideoCallPushPayload.tryParse({