forked from eatvio/eatvio_app
feat(video_calls): incommig call handling fix for ios and anroid
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user