From df634fedd8327cbef995a9daa195bba77b989955 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 5 Oct 2026 13:12:55 +0300 Subject: [PATCH 1/2] feat(video_calls): incommig call handling fix for ios and anroid --- .../app/EatvioFcmMessagingService.kt | 8 +- .../com/eatvioapp/app/VideoCallPushBridge.kt | 2 +- ios/Runner/AppDelegate.swift | 68 +- .../bloc/chats_cubit/chats_cubit.dart | 122 ++- .../managers/video_call_manager.dart | 8 +- .../services/video_call_chat_signal.dart | 85 ++ .../video_call_incoming_push_presenter.dart | 22 +- .../services/video_call_kit_id.dart | 23 + .../services/video_call_push_payload.dart | 20 +- .../presentation/bloc/chats_cubit_test.dart | 951 +++++++++++++----- .../services/video_call_chat_signal_test.dart | 77 ++ .../video_call_push_payload_test.dart | 18 + 12 files changed, 1079 insertions(+), 325 deletions(-) create mode 100644 lib/features/video_calls/services/video_call_chat_signal.dart create mode 100644 lib/features/video_calls/services/video_call_kit_id.dart create mode 100644 test/features/video_calls/services/video_call_chat_signal_test.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt index 4a370800..48ef2a5f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index fbad936e..251ec22f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -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") diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 766a5ed4..1863355a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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( diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index cecc16ab..50414998 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -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 { StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newLastMessageSubscription; StreamSubscription? reconnectedSubscription; + final Set _presentedVideoCallMessageIds = {}; + + /// Test seam. Production uses CallKit. + Future Function(VideoCallPushPayload payload)? presentIncomingVideoCall; + Future 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 { Future 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 { _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 { 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 { 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 { } 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 { 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 { 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 { 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 { } 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 { 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 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 _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 _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 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) { diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index a11d923b..11920783 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -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( diff --git a/lib/features/video_calls/services/video_call_chat_signal.dart b/lib/features/video_calls/services/video_call_chat_signal.dart new file mode 100644 index 00000000..7e0f22b3 --- /dev/null +++ b/lib/features/video_calls/services/video_call_chat_signal.dart @@ -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; + } +} diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 9136a26d..09fa7ab4 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -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 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: { 'conversationUuid': payload.conversationUuid, diff --git a/lib/features/video_calls/services/video_call_kit_id.dart b/lib/features/video_calls/services/video_call_kit_id.dart new file mode 100644 index 00000000..16759108 --- /dev/null +++ b/lib/features/video_calls/services/video_call_kit_id.dart @@ -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(); + } +} diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index 4b1f578d..4a4c2418 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -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 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 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) { diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index f1919b22..030fa7db 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -15,7 +15,12 @@ import 'package:mockito/mockito.dart'; import 'package:dart_pusher_channels/dart_pusher_channels.dart'; import 'chats_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec(), MockSpec()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('ChatsCubit', () { @@ -27,14 +32,26 @@ void main() { const testChatRoomsPagination = ChatRoomPaginationModel( data: [], unreadChatsCount: 0, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 0), + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 0, + ), ); setUp(() { mockChatRepository = MockChatRepository(); mockProfileRepository = MockProfileRepository(); mockWsManager = MockWsManager(); - chatsCubit = ChatsCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, profileRepository: mockProfileRepository); + chatsCubit = ChatsCubit( + chatRepository: mockChatRepository, + wsManager: mockWsManager, + profileRepository: mockProfileRepository, + ); }); tearDown(() async { @@ -49,30 +66,49 @@ void main() { id: chatId, lastMessage: null, users: const Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), ), unreadMessagesCount: unread, chosenPlan: null, ); } - ChannelReadEvent socketEvent({required int id, required int authorId, required String roomId}) { - return _FakeChannelReadEvent(jsonEncode({ - 'id': id, - 'data': 'socket message', - 'media_data': null, - 'author_id': authorId, - 'updated_at': DateTime.now().toIso8601String(), - 'read_by_to_user': false, - 'is_author_message_auth_user': false, - 'room_id': roomId, - })); + ChannelReadEvent socketEvent({ + required int id, + required int authorId, + required String roomId, + String data = 'socket message', + }) { + return _FakeChannelReadEvent( + jsonEncode({ + 'id': id, + 'data': data, + 'media_data': null, + 'author_id': authorId, + 'updated_at': DateTime.now().toIso8601String(), + 'read_by_to_user': false, + 'is_author_message_auth_user': false, + 'room_id': roomId, + }), + ); } Future> connectToSocketStream() async { final controller = StreamController.broadcast(); - when(mockWsManager.newMessagesStream).thenAnswer((_) => controller.stream); + when( + mockWsManager.newMessagesStream, + ).thenAnswer((_) => controller.stream); await chatsCubit.connectToWebSocket(); return controller; } @@ -82,18 +118,25 @@ void main() { expect(chatsCubit.state.state, BaseState.initial); }); - test('getChatRoomsWithLoading - загружает комнаты с loading состоянием', () async { - when(mockChatRepository.getChatRooms(query: null)).thenAnswer((_) async => testChatRoomsPagination); + test( + 'getChatRoomsWithLoading - загружает комнаты с loading состоянием', + () async { + when( + mockChatRepository.getChatRooms(query: null), + ).thenAnswer((_) async => testChatRoomsPagination); - await chatsCubit.getChatRoomsWithLoading(); + await chatsCubit.getChatRoomsWithLoading(); - await Future.delayed(const Duration(milliseconds: 100)); - expect(chatsCubit.state.state, BaseState.success); - verify(mockChatRepository.getChatRooms(query: null)).called(1); - }); + await Future.delayed(const Duration(milliseconds: 100)); + expect(chatsCubit.state.state, BaseState.success); + verify(mockChatRepository.getChatRooms(query: null)).called(1); + }, + ); test('getChatRoomsWithLoading - обрабатывает ошибку', () async { - when(mockChatRepository.getChatRooms(query: null)).thenThrow(Exception('Network error')); + when( + mockChatRepository.getChatRooms(query: null), + ).thenThrow(Exception('Network error')); await chatsCubit.getChatRoomsWithLoading(); @@ -103,7 +146,9 @@ void main() { }); test('getChatRooms - загружает комнаты без loading состояния', () async { - when(mockChatRepository.getChatRooms(query: null)).thenAnswer((_) async => testChatRoomsPagination); + when( + mockChatRepository.getChatRooms(query: null), + ).thenAnswer((_) async => testChatRoomsPagination); await chatsCubit.getChatRooms(); @@ -114,7 +159,9 @@ void main() { test('getChatRooms - загружает комнаты с запросом', () async { const query = 'test query'; - when(mockChatRepository.getChatRooms(query: query)).thenAnswer((_) async => testChatRoomsPagination); + when( + mockChatRepository.getChatRooms(query: query), + ).thenAnswer((_) async => testChatRoomsPagination); await chatsCubit.getChatRooms(query: query); @@ -142,17 +189,29 @@ void main() { const paginationPage2 = ChatRoomPaginationModel( data: [], unreadChatsCount: 0, - meta: Meta(currentPage: 2, from: null, lastPage: 3, links: [], perPage: 10, to: null, total: 30), + meta: Meta( + currentPage: 2, + from: null, + lastPage: 3, + links: [], + perPage: 10, + to: null, + total: 30, + ), ); - when(mockChatRepository.getChatRooms(query: null, page: 1)).thenAnswer((_) async => paginationPage1); + when( + mockChatRepository.getChatRooms(query: null, page: 1), + ).thenAnswer((_) async => paginationPage1); // Сначала загружаем первую страницу await chatsCubit.getChatRooms(); await Future.delayed(const Duration(milliseconds: 100)); // Затем загружаем следующую страницу - when(mockChatRepository.getChatRooms(query: null, page: 2)).thenAnswer((_) async => paginationPage2); + when( + mockChatRepository.getChatRooms(query: null, page: 2), + ).thenAnswer((_) async => paginationPage2); await chatsCubit.loadMoreChatRooms(); @@ -161,7 +220,9 @@ void main() { }); test('loadMoreChatRooms - не загружает если уже загружается', () async { - when(mockChatRepository.getChatRooms(query: null)).thenAnswer((_) async => testChatRoomsPagination); + when( + mockChatRepository.getChatRooms(query: null), + ).thenAnswer((_) async => testChatRoomsPagination); await chatsCubit.getChatRooms(); await Future.delayed(const Duration(milliseconds: 100)); @@ -175,35 +236,64 @@ void main() { verifyNever(mockChatRepository.getChatRooms(query: null, page: 2)); }); - test('reduceUnreadMessagesCount - уменьшает количество непрочитанных сообщений', () { - const chatId = 'chat-id'; - const count = 2; + test( + 'reduceUnreadMessagesCount - уменьшает количество непрочитанных сообщений', + () { + const chatId = 'chat-id'; + const count = 2; - const chatRoom = ChatRoom( - id: chatId, - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 5, - chosenPlan: null, - ); + const chatRoom = ChatRoom( + id: chatId, + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 5, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 1, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 1, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1)); + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: pagination.data, + chatsWithUnreadMessagesCount: 1, + ), + ); - chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); + chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); - final updatedState = chatsCubit.state; - final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId); - expect(updatedRoom.unreadMessagesCount, 3); - }); + final updatedState = chatsCubit.state; + final updatedRoom = updatedState.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(updatedRoom.unreadMessagesCount, 3); + }, + ); test('connectToWebSocket - подключается к WebSocket', () async { const mockStream = Stream.empty(); @@ -214,75 +304,180 @@ void main() { verify(mockWsManager.newMessagesStream).called(1); }); - test('_handleNewMessageViaSocket - собственное сообщение обновляет lastMessage без непрочитанного', () async { - const chatId = 'chat-id'; - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)])); - when(mockWsManager.userId).thenReturn(42); + test( + '_handleNewMessageViaSocket - собственное сообщение обновляет lastMessage без непрочитанного', + () async { + const chatId = 'chat-id'; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); - final controller = await connectToSocketStream(); - controller.add(socketEvent(id: 100, authorId: 42, roomId: chatId)); - await Future.delayed(const Duration(milliseconds: 100)); + final controller = await connectToSocketStream(); + controller.add(socketEvent(id: 100, authorId: 42, roomId: chatId)); + await Future.delayed(const Duration(milliseconds: 100)); - final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId); - expect(room.lastMessage?.id, 100); - expect(room.unreadMessagesCount, 0); - expect(chatsCubit.state.chatsWithUnreadMessagesCount, 0); + final room = chatsCubit.state.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(room.lastMessage?.id, 100); + expect(room.unreadMessagesCount, 0); + expect(chatsCubit.state.chatsWithUnreadMessagesCount, 0); - await controller.close(); - }); + await controller.close(); + }, + ); - test('_handleNewMessageViaSocket - чужое сообщение увеличивает непрочитанное', () async { - const chatId = 'chat-id'; - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)])); - when(mockWsManager.userId).thenReturn(42); + test( + '_handleNewMessageViaSocket - чужое сообщение увеличивает непрочитанное', + () async { + const chatId = 'chat-id'; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); - final controller = await connectToSocketStream(); - controller.add(socketEvent(id: 200, authorId: 7, roomId: chatId)); - await Future.delayed(const Duration(milliseconds: 100)); + final controller = await connectToSocketStream(); + controller.add(socketEvent(id: 200, authorId: 7, roomId: chatId)); + await Future.delayed(const Duration(milliseconds: 100)); - final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId); - expect(room.lastMessage?.id, 200); - expect(room.unreadMessagesCount, 1); - expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); + final room = chatsCubit.state.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(room.lastMessage?.id, 200); + expect(room.unreadMessagesCount, 1); + expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); - await controller.close(); - }); + await controller.close(); + }, + ); - test('_handleNewMessageViaSocket - повторная доставка того же id не двоит непрочитанное', () async { - const chatId = 'chat-id'; - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)])); - when(mockWsManager.userId).thenReturn(42); + test( + '_handleNewMessageViaSocket - повторная доставка того же id не двоит непрочитанное', + () async { + const chatId = 'chat-id'; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); - final controller = await connectToSocketStream(); - controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId)); - await Future.delayed(const Duration(milliseconds: 100)); - controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId)); - await Future.delayed(const Duration(milliseconds: 100)); + final controller = await connectToSocketStream(); + controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId)); + await Future.delayed(const Duration(milliseconds: 100)); + controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId)); + await Future.delayed(const Duration(milliseconds: 100)); - final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId); - expect(room.unreadMessagesCount, 1); - expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); + final room = chatsCubit.state.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(room.unreadMessagesCount, 1); + expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); - await controller.close(); - }); + await controller.close(); + }, + ); - test('_handleNewMessageViaSocket - чужое сообщение в непрочитанном чате не поднимает счётчик чатов', () async { - const chatId = 'chat-id'; - chatsCubit.emit( - ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId, unread: 2)], chatsWithUnreadMessagesCount: 1), - ); - when(mockWsManager.userId).thenReturn(42); + test( + '_handleNewMessageViaSocket - чужое сообщение в непрочитанном чате не поднимает счётчик чатов', + () async { + const chatId = 'chat-id'; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId, unread: 2)], + chatsWithUnreadMessagesCount: 1, + ), + ); + when(mockWsManager.userId).thenReturn(42); - final controller = await connectToSocketStream(); - controller.add(socketEvent(id: 400, authorId: 7, roomId: chatId)); - await Future.delayed(const Duration(milliseconds: 100)); + final controller = await connectToSocketStream(); + controller.add(socketEvent(id: 400, authorId: 7, roomId: chatId)); + await Future.delayed(const Duration(milliseconds: 100)); - final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId); - expect(room.unreadMessagesCount, 3); - expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); + final room = chatsCubit.state.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(room.unreadMessagesCount, 3); + expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1); - await controller.close(); - }); + await controller.close(); + }, + ); + + test( + '_handleNewMessageViaSocket - чужой старт видеозвонка показывает входящий вызов', + () async { + const chatId = 'chat-id'; + final presented = []; + chatsCubit.presentIncomingVideoCall = (payload) async { + presented.add(payload.conversationUuid); + }; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); + + final controller = await connectToSocketStream(); + controller.add( + socketEvent( + id: 500, + authorId: 7, + roomId: chatId, + data: 'Анна начал(а) видеозвонок.', + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(presented, [chatId]); + + await controller.close(); + }, + ); + + test( + '_handleNewMessageViaSocket - свой старт видеозвонка не показывает входящий вызов', + () async { + const chatId = 'chat-id'; + final presented = []; + chatsCubit.presentIncomingVideoCall = (payload) async { + presented.add(payload.conversationUuid); + }; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); + + final controller = await connectToSocketStream(); + controller.add( + socketEvent( + id: 501, + authorId: 42, + roomId: chatId, + data: 'Анна начал(а) видеозвонок.', + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(presented, isEmpty); + + await controller.close(); + }, + ); test('close - закрывает cubit и отписывается от подписок', () async { final mockSubscription1 = MockStreamSubscription(); @@ -300,74 +495,140 @@ void main() { // but the test verifies that the method completes without error }); - test('reduceUnreadMessagesCount - уменьшает до 0 и уменьшает chatsWithUnreadMessagesCount', () { - const chatId = 'chat-id'; - const count = 5; + test( + 'reduceUnreadMessagesCount - уменьшает до 0 и уменьшает chatsWithUnreadMessagesCount', + () { + const chatId = 'chat-id'; + const count = 5; - const chatRoom = ChatRoom( - id: chatId, - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 5, - chosenPlan: null, - ); + const chatRoom = ChatRoom( + id: chatId, + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 5, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 1, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 1, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1)); + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: pagination.data, + chatsWithUnreadMessagesCount: 1, + ), + ); - chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); + chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); - final updatedState = chatsCubit.state; - final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId); - expect(updatedRoom.unreadMessagesCount, 0); - expect(updatedState.chatsWithUnreadMessagesCount, 0); - }); + final updatedState = chatsCubit.state; + final updatedRoom = updatedState.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(updatedRoom.unreadMessagesCount, 0); + expect(updatedState.chatsWithUnreadMessagesCount, 0); + }, + ); - test('reduceUnreadMessagesCount - не уменьшает chatsWithUnreadMessagesCount если unreadMessagesCount > 0', () { - const chatId = 'chat-id'; - const count = 2; + test( + 'reduceUnreadMessagesCount - не уменьшает chatsWithUnreadMessagesCount если unreadMessagesCount > 0', + () { + const chatId = 'chat-id'; + const count = 2; - const chatRoom = ChatRoom( - id: chatId, - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 5, - chosenPlan: null, - ); + const chatRoom = ChatRoom( + id: chatId, + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 5, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 1, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 1, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1)); + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: pagination.data, + chatsWithUnreadMessagesCount: 1, + ), + ); - chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); + chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); - final updatedState = chatsCubit.state; - expect(updatedState.chatsWithUnreadMessagesCount, 1); - }); + final updatedState = chatsCubit.state; + expect(updatedState.chatsWithUnreadMessagesCount, 1); + }, + ); test('loadMoreChatRooms - не загружает если lastPage == page', () async { const paginationPage1 = ChatRoomPaginationModel( data: [], unreadChatsCount: 0, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 10), + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 10, + ), ); - when(mockChatRepository.getChatRooms(query: null, page: 1)).thenAnswer((_) async => paginationPage1); + when( + mockChatRepository.getChatRooms(query: null, page: 1), + ).thenAnswer((_) async => paginationPage1); await chatsCubit.getChatRooms(); await Future.delayed(const Duration(milliseconds: 100)); @@ -382,11 +643,23 @@ void main() { const paginationPage1 = ChatRoomPaginationModel( data: [], unreadChatsCount: 0, - meta: Meta(currentPage: 1, from: null, lastPage: 3, links: [], perPage: 10, to: null, total: 30), + meta: Meta( + currentPage: 1, + from: null, + lastPage: 3, + links: [], + perPage: 10, + to: null, + total: 30, + ), ); - when(mockChatRepository.getChatRooms(query: null, page: 1)).thenAnswer((_) async => paginationPage1); - when(mockChatRepository.getChatRooms(query: null, page: 2)).thenThrow(Exception('Ошибка загрузки')); + when( + mockChatRepository.getChatRooms(query: null, page: 1), + ).thenAnswer((_) async => paginationPage1); + when( + mockChatRepository.getChatRooms(query: null, page: 2), + ).thenThrow(Exception('Ошибка загрузки')); await chatsCubit.getChatRooms(); await Future.delayed(const Duration(milliseconds: 100)); @@ -400,7 +673,9 @@ void main() { }); test('getChatRooms - обрабатывает ошибку', () async { - when(mockChatRepository.getChatRooms(query: null)).thenThrow(Exception('Network error')); + when( + mockChatRepository.getChatRooms(query: null), + ).thenThrow(Exception('Network error')); await chatsCubit.getChatRooms(); @@ -415,8 +690,18 @@ void main() { id: chatId, lastMessage: null, users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), ), unreadMessagesCount: 0, chosenPlan: null, @@ -425,10 +710,20 @@ void main() { const pagination = ChatRoomPaginationModel( data: [chatRoom], unreadChatsCount: 0, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data)); + chatsCubit.emit( + ChatsState(state: BaseState.success, chatRooms: pagination.data), + ); final newMessage = ChatMessageModel( id: 1, @@ -447,154 +742,258 @@ void main() { // Или проверяем через публичный API, если есть // В данном случае, _setNewLastMessage вызывается через newUserMessagesStream final streamController = StreamController(); - when(mockChatRepository.newUserMessagesStream).thenAnswer((_) => streamController.stream); + when( + mockChatRepository.newUserMessagesStream, + ).thenAnswer((_) => streamController.stream); // Пересоздаем cubit чтобы подписаться на stream await chatsCubit.close(); - chatsCubit = ChatsCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, profileRepository: mockProfileRepository); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data)); + chatsCubit = ChatsCubit( + chatRepository: mockChatRepository, + wsManager: mockWsManager, + profileRepository: mockProfileRepository, + ); + chatsCubit.emit( + ChatsState(state: BaseState.success, chatRooms: pagination.data), + ); streamController.add(newMessage); await Future.delayed(const Duration(milliseconds: 100)); final updatedState = chatsCubit.state; - final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId); + final updatedRoom = updatedState.chatRooms.firstWhere( + (r) => r.id == chatId, + ); expect(updatedRoom.lastMessage, isNotNull); expect(updatedRoom.lastMessage?.id, 1); await streamController.close(); }); - test('_setNewLastMessage - обновляет последнее сообщение через newUserMessagesStream', () async { - const chatId = 'chat-id'; - const chatRoom = ChatRoom( - id: chatId, - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 0, - chosenPlan: null, - ); + test( + '_setNewLastMessage - обновляет последнее сообщение через newUserMessagesStream', + () async { + const chatId = 'chat-id'; + const chatRoom = ChatRoom( + id: chatId, + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 0, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 0, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 0, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - final newMessage = ChatMessageModel( - id: 1, - data: 'Test message', - mediaData: null, - urlsMediaInfo: null, - authorId: 1, - updatedAt: DateTime.now(), - isRead: false, - isCurrentUserAuthor: true, - roomId: chatId, - type: ChatMessageType.text, - ); + final newMessage = ChatMessageModel( + id: 1, + data: 'Test message', + mediaData: null, + urlsMediaInfo: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: true, + roomId: chatId, + type: ChatMessageType.text, + ); - final streamController = StreamController(); - when(mockChatRepository.newUserMessagesStream).thenAnswer((_) => streamController.stream); + final streamController = StreamController(); + when( + mockChatRepository.newUserMessagesStream, + ).thenAnswer((_) => streamController.stream); - // Пересоздаем cubit чтобы подписаться на stream - await chatsCubit.close(); - chatsCubit = ChatsCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, profileRepository: mockProfileRepository); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data)); + // Пересоздаем cubit чтобы подписаться на stream + await chatsCubit.close(); + chatsCubit = ChatsCubit( + chatRepository: mockChatRepository, + wsManager: mockWsManager, + profileRepository: mockProfileRepository, + ); + chatsCubit.emit( + ChatsState(state: BaseState.success, chatRooms: pagination.data), + ); - streamController.add(newMessage); - await Future.delayed(const Duration(milliseconds: 100)); + streamController.add(newMessage); + await Future.delayed(const Duration(milliseconds: 100)); - final updatedState = chatsCubit.state; - final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId); - expect(updatedRoom.lastMessage, isNotNull); - expect(updatedRoom.lastMessage?.id, 1); + final updatedState = chatsCubit.state; + final updatedRoom = updatedState.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(updatedRoom.lastMessage, isNotNull); + expect(updatedRoom.lastMessage?.id, 1); - await streamController.close(); - }); + await streamController.close(); + }, + ); - test('reduceUnreadMessagesCount - обрабатывает чат который не существует', () { - const chatId = 'non-existent-chat'; - const count = 2; + test( + 'reduceUnreadMessagesCount - обрабатывает чат который не существует', + () { + const chatId = 'non-existent-chat'; + const count = 2; - const chatRoom = ChatRoom( - id: 'other-chat-id', - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 5, - chosenPlan: null, - ); + const chatRoom = ChatRoom( + id: 'other-chat-id', + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 5, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 1, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 1, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1)); + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: pagination.data, + chatsWithUnreadMessagesCount: 1, + ), + ); - chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); + chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); - final updatedState = chatsCubit.state; - // Чат не должен измениться - expect(updatedState.chatRooms.length, 1); - expect(updatedState.chatRooms.first.unreadMessagesCount, 5); - expect(updatedState.chatsWithUnreadMessagesCount, 1); - }); + final updatedState = chatsCubit.state; + // Чат не должен измениться + expect(updatedState.chatRooms.length, 1); + expect(updatedState.chatRooms.first.unreadMessagesCount, 5); + expect(updatedState.chatsWithUnreadMessagesCount, 1); + }, + ); - test('reduceUnreadMessagesCount - обрабатывает уменьшение больше чем unreadMessagesCount', () { - const chatId = 'chat-id'; - const count = 10; // Больше чем unreadMessagesCount + test( + 'reduceUnreadMessagesCount - обрабатывает уменьшение больше чем unreadMessagesCount', + () { + const chatId = 'chat-id'; + const count = 10; // Больше чем unreadMessagesCount - const chatRoom = ChatRoom( - id: chatId, - lastMessage: null, - users: Users( - fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''), - toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''), - ), - unreadMessagesCount: 5, - chosenPlan: null, - ); + const chatRoom = ChatRoom( + id: chatId, + lastMessage: null, + users: Users( + fromChatUser: ChatUser( + id: 1, + name: 'User 1', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + toChatUser: ChatUser( + id: 2, + name: 'User 2', + profilePhotoPath: null, + profilePhotoUrl: '', + ), + ), + unreadMessagesCount: 5, + chosenPlan: null, + ); - const pagination = ChatRoomPaginationModel( - data: [chatRoom], - unreadChatsCount: 1, - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1), - ); + const pagination = ChatRoomPaginationModel( + data: [chatRoom], + unreadChatsCount: 1, + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 1, + ), + ); - chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1)); + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: pagination.data, + chatsWithUnreadMessagesCount: 1, + ), + ); - chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); + chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count); - final updatedState = chatsCubit.state; - final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId); - expect(updatedRoom.unreadMessagesCount, 0); - expect(updatedState.chatsWithUnreadMessagesCount, 0); - }); + final updatedState = chatsCubit.state; + final updatedRoom = updatedState.chatRooms.firstWhere( + (r) => r.id == chatId, + ); + expect(updatedRoom.unreadMessagesCount, 0); + expect(updatedState.chatsWithUnreadMessagesCount, 0); + }, + ); - test('connectToWebSocket - обновляет список чатов после реконнекта', () async { - final reconnectController = StreamController.broadcast(); + test( + 'connectToWebSocket - обновляет список чатов после реконнекта', + () async { + final reconnectController = StreamController.broadcast(); - when(mockWsManager.reconnected).thenAnswer((_) => reconnectController.stream); - when(mockWsManager.newMessagesStream).thenReturn(null); - when(mockChatRepository.getChatRooms(query: null)).thenAnswer((_) async => testChatRoomsPagination); + when( + mockWsManager.reconnected, + ).thenAnswer((_) => reconnectController.stream); + when(mockWsManager.newMessagesStream).thenReturn(null); + when( + mockChatRepository.getChatRooms(query: null), + ).thenAnswer((_) async => testChatRoomsPagination); - await chatsCubit.connectToWebSocket(); + await chatsCubit.connectToWebSocket(); - reconnectController.add(null); - await Future.delayed(const Duration(milliseconds: 100)); + reconnectController.add(null); + await Future.delayed(const Duration(milliseconds: 100)); - verify(mockChatRepository.getChatRooms(query: null)).called(1); + verify(mockChatRepository.getChatRooms(query: null)).called(1); - await reconnectController.close(); - }); + await reconnectController.close(); + }, + ); }); } diff --git a/test/features/video_calls/services/video_call_chat_signal_test.dart b/test/features/video_calls/services/video_call_chat_signal_test.dart new file mode 100644 index 00000000..a34d6529 --- /dev/null +++ b/test/features/video_calls/services/video_call_chat_signal_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 4e30a006..9e716cba 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -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({ From 61cded0081232584ea5735221cc1ea3f07d52d2b Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 5 Oct 2026 13:13:25 +0300 Subject: [PATCH 2/2] version cnhanged --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 67488f48..3d726109 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: