From 141cf5543d4fef39704bc40cebdff6f922386f28 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 21 Oct 2026 14:25:59 +0300 Subject: [PATCH] feat(video_calls): continous ringing on a defferent device fix --- .../app/EatvioFcmMessagingService.kt | 2 +- .../com/eatvioapp/app/HmsPushService.kt | 2 +- .../kotlin/com/eatvioapp/app/MainActivity.kt | 11 +- .../com/eatvioapp/app/VideoCallPushBridge.kt | 170 ++++++++++++++++-- .../push_navigation_handler.dart | 6 +- .../bloc/chats_cubit/chats_cubit.dart | 4 +- .../managers/video_call_manager.dart | 98 ++++++++-- .../video_calls/models/video_call_models.dart | 26 ++- .../repositories/video_call_repository.dart | 38 +++- .../video_call_callkit_coordinator.dart | 24 ++- .../services/video_call_end_reason.dart | 35 ++++ .../video_call_ended_push_handler.dart | 101 ++++++++++- .../video_call_ended_push_payload.dart | 55 ++++-- .../services/video_call_fcm_handler.dart | 6 +- .../video_call_incoming_push_presenter.dart | 27 ++- .../services/video_call_kit_id.dart | 29 ++- .../services/video_call_push_payload.dart | 20 +++ .../services/video_call_service.dart | 7 + .../video_call_tombstone_registry.dart | 37 ++++ .../video_call_manager_lifecycle_test.dart | 37 +++- .../video_call_repository_test.dart | 76 +++++++- .../video_call_ended_push_handler_test.dart | 64 +++++++ .../video_call_push_payload_test.dart | 40 +++++ .../services/video_call_service_test.dart | 21 ++- .../video_call_tombstone_registry_test.dart | 22 +++ .../video_call_decline_response.json | 6 + 26 files changed, 871 insertions(+), 93 deletions(-) create mode 100644 lib/features/video_calls/services/video_call_end_reason.dart create mode 100644 lib/features/video_calls/services/video_call_tombstone_registry.dart create mode 100644 test/features/video_calls/services/video_call_tombstone_registry_test.dart create mode 100644 test/fixtures/video_calls/video_call_decline_response.json 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 48ef2a5f..26e623d1 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() { Log.i(TAG, "onMessageReceived: ${message.data}") if (VideoCallPushBridge.isEndedVideoCall(message.data)) { - VideoCallPushBridge.dismissAllIncomingCalls(this) + VideoCallPushBridge.dismissEndedVideoCall(this, message.data) return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index 5c1c0d3e..fd3e68c7 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() { if (message == null) return if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { - VideoCallPushBridge.dismissAllIncomingCalls(this) + VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap) return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index a30cd589..dc110843 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -59,7 +59,16 @@ class MainActivity: FlutterActivity() { when (call.method) { "dismissIncomingCalls" -> { try { - VideoCallPushBridge.dismissAllIncomingCalls(applicationContext) + val args = call.arguments as? Map<*, *> + val callId = args?.get("call_id")?.toString() + ?: args?.get("callId")?.toString() + val conversationUuid = args?.get("conversation_uuid")?.toString() + ?: args?.get("conversationUuid")?.toString() + VideoCallPushBridge.dismissIncomingCalls( + applicationContext, + callId = callId, + conversationUuid = conversationUuid, + ) result.success(null) } catch (e: Exception) { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) 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 50afdac6..d901ada3 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -18,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls +import com.hiennv.flutter_callkit_incoming.removeCall import java.util.HashMap import java.util.concurrent.atomic.AtomicInteger @@ -25,11 +26,13 @@ object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring" private val mainHandler = Handler(Looper.getMainLooper()) private val playbackLock = Any() private val playbackGeneration = AtomicInteger(0) + private val tombstonedCallIds = HashMap() private var activeSoundManager: CallkitSoundPlayerManager? = null private var activeNotificationManager: CallkitNotificationManager? = null @@ -118,6 +121,12 @@ object VideoCallPushBridge { storedAppContext = appContext ensureIncomingCallCallbackRegistered() + val serverCallId = callId(data) + if (isTombstoned(serverCallId)) { + Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId") + return + } + val callData = buildCallDataBundle(data) if (callData == null) { Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") @@ -126,7 +135,7 @@ object VideoCallPushBridge { val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID) val seenGeneration = playbackGeneration.get() - Log.i(TAG, "showIncomingCall: conversation=$callId") + Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}") // Ringtone.play() and stop() must run on the same thread. FCM delivers // pushes on a background thread, while Answer runs on the main thread. @@ -180,17 +189,43 @@ object VideoCallPushBridge { } } - fun dismissAllIncomingCalls(context: Context) { + fun dismissIncomingCalls( + context: Context, + callId: String? = null, + conversationUuid: String? = null, + ) { + val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() } + val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedCallId != null) { + markTombstone(normalizedCallId) + } + val appContext = context.applicationContext val calls = getDataActiveCalls(appContext) - Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + Log.i( + TAG, + "dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " + + "conversation=${normalizedConversation ?: "-"}", + ) playbackGeneration.incrementAndGet() val dismiss = Runnable { synchronized(playbackLock) { val notificationManager = resolveNotificationManager(appContext) - val bundles = calls.map { it.toBundle() } - stopPlaybackLocked(appContext) + val allBundles = calls.map { it.toBundle() } + val targeted = hasDismissTarget(normalizedCallId, normalizedConversation) + val bundles = if (targeted) { + allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) } + } else { + allBundles + } + + val activeMatches = activeCallBundle?.let { bundle -> + !targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation) + } == true + if (activeMatches || !targeted) { + stopPlaybackLocked(appContext) + } for (bundle in bundles) { try { @@ -198,25 +233,39 @@ object VideoCallPushBridge { } catch (error: Exception) { Log.w( TAG, - "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", + "dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", error, ) } } - try { - CallkitNotificationService.stopService(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) + if (!targeted || bundles.isNotEmpty()) { + try { + CallkitNotificationService.stopService(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error) + } } - try { - removeAllCalls(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + if (!targeted) { + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error) + } + } else { + for (bundle in bundles) { + try { + removeCall(appContext, Data.fromBundle(bundle)) + } catch (error: Exception) { + Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error) + } + } } - stopIncomingRingtone() + if (activeMatches || !targeted) { + stopIncomingRingtone() + } scheduleMissedCallCleanup(notificationManager, bundles) } } @@ -228,6 +277,18 @@ object VideoCallPushBridge { } } + fun dismissAllIncomingCalls(context: Context) { + dismissIncomingCalls(context) + } + + fun dismissEndedVideoCall(context: Context, data: Map) { + dismissIncomingCalls( + context = context, + callId = callId(data), + conversationUuid = conversationUuid(data), + ) + } + private fun ensureIncomingCallCallbackRegistered() { if (callbackRegistered) return FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback) @@ -403,6 +464,8 @@ object VideoCallPushBridge { private fun buildCallDataBundle(data: Map): Bundle? { val conversationUuid = conversationUuid(data) ?: return null + val serverCallId = callId(data) + val callKitId = serverCallId ?: conversationUuid val callerName = data["counterpartName"] ?: data["counterpart_name"] ?: data["caller_name"] @@ -428,6 +491,9 @@ object VideoCallPushBridge { val extra = HashMap() extra["conversationUuid"] = conversationUuid + if (!serverCallId.isNullOrBlank()) { + extra["call_id"] = serverCallId + } extra["counterpartName"] = callerName if (avatarUrl.isNotBlank()) { extra["counterpartAvatarUrl"] = avatarUrl @@ -438,9 +504,13 @@ object VideoCallPushBridge { if (counterpartIsConsultant) { extra["counterpartIsConsultant"] = true } + val messageId = data["message_id"] ?: data["messageId"] + if (!messageId.isNullOrBlank()) { + extra["message_id"] = messageId + } return Bundle().apply { - putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId) putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) @@ -465,6 +535,74 @@ object VideoCallPushBridge { } } + private fun callId(data: Map): String? { + return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() } + ?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean { + return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank() + } + + private fun bundleMatches( + bundle: Bundle, + callId: String?, + conversationUuid: String?, + ): Boolean { + val normalizedCallId = callId?.trim()?.lowercase() + if (!normalizedCallId.isNullOrEmpty()) { + val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase() + if (id != null && id == normalizedCallId) return true + val extraCallId = extraString(bundle, "call_id") + ?: extraString(bundle, "callId") + if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) { + return true + } + return false + } + + val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false + val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase() + if (handle != null && handle == normalizedConversation) return true + val extraConversation = extraString(bundle, "conversationUuid") + ?: extraString(bundle, "conversation_uuid") + return extraConversation?.lowercase() == normalizedConversation + } + + @Suppress("UNCHECKED_CAST") + private fun extraString(bundle: Bundle, key: String): String? { + val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap + ?: return null + val value = extra[key] ?: return null + val text = value.toString().trim() + return text.takeIf { it.isNotEmpty() } + } + + private fun markTombstone(callId: String) { + val normalized = callId.trim().lowercase() + if (normalized.isEmpty()) return + pruneTombstones() + tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS + } + + private fun isTombstoned(callId: String?): Boolean { + val normalized = callId?.trim()?.lowercase() ?: return false + if (normalized.isEmpty()) return false + pruneTombstones() + return tombstonedCallIds.containsKey(normalized) + } + + private fun pruneTombstones() { + val now = System.currentTimeMillis() + val iterator = tombstonedCallIds.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.value <= now) { + iterator.remove() + } + } + } + private fun conversationUuid(data: Map): String? { val fromData = data["conversationUuid"] ?: data["conversation_uuid"] diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index b6481d6d..189dfc82 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -62,12 +62,16 @@ class PushNavigationHandler { final endedPayload = VideoCallEndedPushPayload.tryParse(data); if (endedPayload != null) { developer.log( - 'PushNavigationHandler: dismiss incoming call UI, source=$source', + 'PushNavigationHandler: dismiss incoming call UI, source=$source ' + 'call_id=${endedPayload.callId ?? '-'} ' + 'reason=${endedPayload.reason?.name ?? '-'}', name: 'PushNavigationHandler', ); unawaited( VideoCallEndedPushHandler.dismissIncomingCallUi( conversationUuid: endedPayload.conversationUuid, + callId: endedPayload.callId, + reason: endedPayload.reason, ), ); return; 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 f9f72353..e166c84b 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -232,7 +232,9 @@ class ChatsCubit extends Cubit { if (kind == VideoCallChatSignalKind.ended) { unawaited(_dismissIncomingVideoCallUi(message.roomId)); unawaited( - VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId), + VideoCallEndedPushHandler.endActiveCallIfMatches( + conversationUuid: message.roomId, + ), ); return; } diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index c7bdd5a5..197c2668 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -32,12 +32,14 @@ class VideoCallManager { VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, + Future Function()? resolveHwId, }) : _videoCallRepository = videoCallRepository, _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = - audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() { + audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(), + _resolveHwId = resolveHwId ?? DeviceHwIdProvider.current { FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData); if (Platform.isIOS) { // LiveKit rewrites the audio session whenever a remote audio track @@ -52,6 +54,7 @@ class VideoCallManager { final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; + final Future Function() _resolveHwId; static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions( @@ -78,6 +81,8 @@ class VideoCallManager { bool _callEndedNotified = true; bool _forceEndRequested = false; bool _sessionMayRequestToken = false; + String? _activeCallId; + final Set _callDeclineNotifiedKeys = {}; final Map _leftConversationAt = {}; final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; @@ -102,6 +107,14 @@ class VideoCallManager { bool get callEndedNotified => _callEndedNotified; + String? get activeCallId => _activeCallId; + + void rememberCallId(String? callId) { + final id = callId?.trim(); + if (id == null || id.isEmpty) return; + _activeCallId = id; + } + bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && @@ -441,7 +454,10 @@ class VideoCallManager { await _disconnectLiveKitOnly(); if (shouldNotifyBackend) { try { - await _videoCallRepository.notifyCallEnded(uuid); + await _videoCallRepository.notifyCallEnded( + uuid, + callId: _activeCallId, + ); } catch (_) {} } await _foreground.stopOngoingCall(); @@ -451,17 +467,22 @@ class VideoCallManager { _videoPausedForBackground = false; _liveKitDisposed = true; _myProfileNameFuture = null; + _activeCallId = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } /// Notifies backend that this device accepted an incoming call so ringing /// stops on the user's other devices. - Future notifyIncomingCallAccepted(String conversationUuid) async { + Future notifyIncomingCallAccepted( + String conversationUuid, { + String? callId, + }) async { final uuid = conversationUuid.trim(); if (uuid.isEmpty) return; + rememberCallId(callId); if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; - final hwId = (await DeviceHwIdProvider.current())?.trim(); + final hwId = (await _resolveHwId())?.trim(); if (hwId == null || hwId.length < 8) return; _callAcceptNotifiedConversationUuids.add(uuid); @@ -469,6 +490,7 @@ class VideoCallManager { await _videoCallRepository.notifyCallAccepted( conversationUuid: uuid, hwId: hwId, + callId: _activeCallId ?? callId, ); } catch (_) { _callAcceptNotifiedConversationUuids.remove(uuid); @@ -476,28 +498,67 @@ class VideoCallManager { } /// Decline from incoming UI before joining the LiveKit room. - Future declineIncomingCall(String conversationUuid) async { + /// + /// Uses `POST /decline` so the backend can stop ringing on the user's + /// other devices. Do not call `/ended` for a ringing decline. + Future declineIncomingCall( + String conversationUuid, { + String? callId, + }) async { final uuid = conversationUuid.trim(); + final resolvedCallId = callId?.trim().isNotEmpty == true + ? callId!.trim() + : _activeCallId; if (uuid.isEmpty) { - await endCall(); + await _cleanupLocalCallAfterDecline(); return; } - if (_snapshot.conversationUuid == uuid) { - await endCall(); - return; - } - - await endCall(); - if (!incomingDeclineShouldNotifyBackend( + final declineKey = '${uuid.toLowerCase()}|${resolvedCallId ?? ''}'; + final shouldNotify = incomingDeclineShouldNotifyBackend( leftAt: _leftConversationAt[uuid], now: DateTime.now(), - )) { + ); + + await _cleanupLocalCallAfterDecline(conversationUuid: uuid); + + if (!shouldNotify || _callDeclineNotifiedKeys.contains(declineKey)) { return; } + + final hwId = (await _resolveHwId())?.trim(); + if (hwId == null || hwId.length < 8) return; + + _callDeclineNotifiedKeys.add(declineKey); try { - await _videoCallRepository.notifyCallEnded(uuid); - } catch (_) {} + await _videoCallRepository.notifyCallDeclined( + conversationUuid: uuid, + hwId: hwId, + callId: resolvedCallId, + ); + } catch (_) { + _callDeclineNotifiedKeys.remove(declineKey); + } + } + + Future _cleanupLocalCallAfterDecline({String? conversationUuid}) async { + final uuid = conversationUuid?.trim(); + if (uuid != null && uuid.isNotEmpty) { + _leftConversationAt[uuid] = DateTime.now(); + } + _callEndedNotified = true; + _sessionMayRequestToken = false; + _disposeRoomEphemerals(); + await _disconnectLiveKitOnly(); + await _foreground.stopOngoingCall(); + await _stopCallAudioSession(); + _lifecycleBinding.detach(); + _callStageEntered = false; + _videoPausedForBackground = false; + _liveKitDisposed = true; + _myProfileNameFuture = null; + _activeCallId = null; + _emitSnapshot(VideoCallManagerSnapshot.initial); } /// [currentUiEnabled] reflects what the user sees on the mic control @@ -805,6 +866,7 @@ class VideoCallManager { _roomConnectFuture = null; _clearPreparedConnectionCredentials(); _callAcceptNotifiedConversationUuids.clear(); + _callDeclineNotifiedKeys.clear(); _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false)); } @@ -1011,9 +1073,11 @@ class VideoCallManager { try { final tokenResponse = await _videoCallRepository.getParticipantToken( _snapshot.conversationUuid, + callId: _activeCallId, ); if (_liveKitDisposed) return; + rememberCallId(tokenResponse.callId); _preparedWsUrl = tokenResponse.wsUrl; _preparedToken = tokenResponse.token; @@ -1058,8 +1122,10 @@ class VideoCallManager { if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return; final tokenResponse = await _videoCallRepository.getParticipantToken( _snapshot.conversationUuid, + callId: _activeCallId, ); if (_liveKitDisposed) return; + rememberCallId(tokenResponse.callId); wsUrl = tokenResponse.wsUrl; token = tokenResponse.token; _preparedWsUrl = wsUrl; diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart index f344822d..e176d040 100644 --- a/lib/features/video_calls/models/video_call_models.dart +++ b/lib/features/video_calls/models/video_call_models.dart @@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse { required String token, required String roomName, required String wsUrl, + @JsonKey(name: 'call_id') String? callId, + @JsonKey(name: 'call_status') String? callStatus, + @JsonKey(name: 'call_expires_at') String? callExpiresAt, }) = _VideoCallTokenResponse; factory VideoCallTokenResponse.fromJson(Map json) => @@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse { @freezed abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { - const factory VideoCallEndedResponse({required bool sent}) = - _VideoCallEndedResponse; + const factory VideoCallEndedResponse({ + required bool sent, + @JsonKey(name: 'call_id') String? callId, + String? status, + }) = _VideoCallEndedResponse; factory VideoCallEndedResponse.fromJson(Map json) => _$VideoCallEndedResponseFromJson(json); @@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse { const factory VideoCallAcceptResponse({ required bool sent, @JsonKey(name: 'devices_notified') required int devicesNotified, + @JsonKey(name: 'call_id') String? callId, + String? status, + @JsonKey(name: 'already_accepted') bool? alreadyAccepted, }) = _VideoCallAcceptResponse; factory VideoCallAcceptResponse.fromJson(Map json) => _$VideoCallAcceptResponseFromJson(json); } + +@freezed +abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse { + const factory VideoCallDeclineResponse({ + required bool sent, + @JsonKey(name: 'devices_notified') required int devicesNotified, + @JsonKey(name: 'call_id') String? callId, + String? status, + }) = _VideoCallDeclineResponse; + + factory VideoCallDeclineResponse.fromJson(Map json) => + _$VideoCallDeclineResponseFromJson(json); +} diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart index 15cbb4f1..7d105451 100644 --- a/lib/features/video_calls/repositories/video_call_repository.dart +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -8,9 +8,13 @@ class VideoCallRepository { static const Map _emptyJsonBody = {}; - Future getParticipantToken(String conversationUuid) { + Future getParticipantToken( + String conversationUuid, { + String? callId, + }) { return videoCallService.getParticipantToken( conversationUuid: conversationUuid, + callId: callId, ); } @@ -18,20 +22,46 @@ class VideoCallRepository { return videoCallService.getAdminToken(conversationUuid: conversationUuid); } - Future notifyCallEnded(String conversationUuid) { + Future notifyCallEnded( + String conversationUuid, { + String? callId, + }) { + final body = { + if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(), + }; return videoCallService.notifyCallEnded( conversationUuid: conversationUuid, - body: _emptyJsonBody, + body: body.isEmpty ? _emptyJsonBody : body, ); } Future notifyCallAccepted({ required String conversationUuid, required String hwId, + String? callId, }) { return videoCallService.notifyCallAccepted( conversationUuid: conversationUuid, - body: {'hw_id': hwId}, + body: { + 'hw_id': hwId, + if (callId != null && callId.trim().isNotEmpty) + 'call_id': callId.trim(), + }, + ); + } + + Future notifyCallDeclined({ + required String conversationUuid, + required String hwId, + String? callId, + }) { + return videoCallService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: { + 'hw_id': hwId, + if (callId != null && callId.trim().isNotEmpty) + 'call_id': callId.trim(), + }, ); } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index f57b6745..41601efb 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -701,7 +701,18 @@ class VideoCallCallKitCoordinator { if (_acceptNavigationCompletedIds.contains(callKitId)) return; _suppressedEndCallKitIds.add(callKitId); - unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + final params = _paramsForCallId(callKitId); + final callId = _parseCallId(params?.extra ?? const {}) ?? + (callKitId.contains('-') ? callKitId : null); + if (callId != null) { + _manager.rememberCallId(callId); + } + unawaited( + _manager.notifyIncomingCallAccepted( + conversationUuid, + callId: callId, + ), + ); try { await _waitForAcceptNavigationReady(); @@ -874,6 +885,8 @@ class VideoCallCallKitCoordinator { extra, handle: params?.handle ?? callKitId, ); + final callId = _parseCallId(extra) ?? + (callKitId.contains('-') ? callKitId : null); final remoteEndDismissal = callKitDeclineIsEndedPush( cachedExtra: params?.extra, @@ -911,7 +924,10 @@ class VideoCallCallKitCoordinator { return; } - await _manager.declineIncomingCall(conversationUuid); + await _manager.declineIncomingCall( + conversationUuid, + callId: callId, + ); if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.onCallEndedExternally(); } @@ -977,6 +993,10 @@ class VideoCallCallKitCoordinator { return ''; } + static String? _parseCallId(Map extra) { + return _parseString(extra, const ['call_id', 'callId']); + } + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; diff --git a/lib/features/video_calls/services/video_call_end_reason.dart b/lib/features/video_calls/services/video_call_end_reason.dart new file mode 100644 index 00000000..4f962a3c --- /dev/null +++ b/lib/features/video_calls/services/video_call_end_reason.dart @@ -0,0 +1,35 @@ +/// Cause of a `video_call_ended` push / Echo event. +/// +/// This is not the session status. It tells the client how to close ringing +/// or an active call. +enum VideoCallEndReason { + accepted, + declined, + cancelled, + ended, + timeout; + + static VideoCallEndReason? tryParse(Object? raw) { + if (raw == null) return null; + final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_'); + if (normalized.isEmpty) return null; + for (final value in VideoCallEndReason.values) { + if (value.name == normalized) return value; + } + if (normalized == 'hangup' || normalized == 'hang_up') { + return VideoCallEndReason.ended; + } + return null; + } + + /// Close ringing UI; do not tear down an already-accepted LiveKit call. + bool get dismissesRingingOnly => + this == VideoCallEndReason.accepted || + this == VideoCallEndReason.declined || + this == VideoCallEndReason.cancelled || + this == VideoCallEndReason.timeout; + + /// Tear down an accepted in-progress call. + bool get endsActiveCall => + this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout; +} diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 9f50cc39..537b05d3 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -1,8 +1,11 @@ +import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -13,30 +16,99 @@ abstract final class VideoCallEndedPushHandler { 'com.eatvioapp/video_call_push', ); - static Future dismissIncomingCallUi({String? conversationUuid}) async { - await endActiveCallIfMatches(conversationUuid); + static Future dismissIncomingCallUi({ + String? conversationUuid, + String? callId, + VideoCallEndReason? reason, + }) async { + final normalizedCallId = callId?.trim(); + if (normalizedCallId != null && normalizedCallId.isNotEmpty) { + VideoCallTombstoneRegistry.mark(normalizedCallId); + } + + log( + 'VideoCallEndedPushHandler: dismiss ' + 'call_id=${normalizedCallId ?? '-'} ' + 'conversation=${conversationUuid ?? '-'} ' + 'reason=${reason?.name ?? '-'}', + name: 'VideoCallEndedPush', + ); + + final shouldEndActive = reason == null || reason.endsActiveCall; + if (shouldEndActive && reason != VideoCallEndReason.accepted) { + await endActiveCallIfMatches( + conversationUuid: conversationUuid, + callId: normalizedCallId, + ); + } + if (Platform.isAndroid) { try { - await _androidPushChannel.invokeMethod('dismissIncomingCalls'); + await _androidPushChannel.invokeMethod( + 'dismissIncomingCalls', + { + if (normalizedCallId != null && normalizedCallId.isNotEmpty) + 'call_id': normalizedCallId, + if (conversationUuid != null && conversationUuid.trim().isNotEmpty) + 'conversation_uuid': conversationUuid.trim(), + }, + ); return; } catch (_) {} } - await _dismissViaCallKit(conversationUuid); + await _dismissViaCallKit( + conversationUuid: conversationUuid, + callId: normalizedCallId, + ); } - static Future endActiveCallIfMatches(String? conversationUuid) async { + static Future endActiveCallIfMatches({ + String? conversationUuid, + String? callId, + }) async { final uuid = conversationUuid?.trim(); if (uuid == null || uuid.isEmpty) return; if (!sl.isRegistered()) return; final cubit = sl(); if (cubit.state.conversationUuid != uuid) return; + // callId is reserved for ringing dismiss matching; active sessions are + // still keyed by conversation until the cubit stores call_id. try { await cubit.handleRemoteCallEnded(); } catch (_) {} } + static bool callMatches({ + required CallKitParams call, + String? conversationUuid, + String? callId, + }) { + final normalizedCallId = callId?.trim().toLowerCase(); + if (normalizedCallId != null && normalizedCallId.isNotEmpty) { + final id = call.id.trim().toLowerCase(); + if (id.isNotEmpty && id == normalizedCallId) return true; + final extra = call.extra; + if (extra != null) { + for (final key in const ['call_id', 'callId']) { + final value = extra[key]; + if (value is String && + value.trim().toLowerCase() == normalizedCallId) { + return true; + } + } + } + // When call_id is known, never fall back to conversation matching: + // a stale ended event must not close a newer ring in the same chat. + return false; + } + + final uuid = conversationUuid?.trim().toLowerCase(); + if (uuid == null || uuid.isEmpty) return false; + return callMatchesConversation(call, uuid); + } + static bool callMatchesConversation( CallKitParams call, String conversationUuid, @@ -57,11 +129,18 @@ abstract final class VideoCallEndedPushHandler { /// The close of a ringing call comes back from the plugin as a decline. /// Each id is marked first, so the coordinator does not report that /// decline to the backend as if the user had rejected the call. - static Future _dismissViaCallKit(String? conversationUuid) async { + static Future _dismissViaCallKit({ + String? conversationUuid, + String? callId, + }) async { final uuid = conversationUuid?.trim(); + final normalizedCallId = callId?.trim(); try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (uuid == null || uuid.isEmpty) { + final hasTarget = + (normalizedCallId != null && normalizedCallId.isNotEmpty) || + (uuid != null && uuid.isNotEmpty); + if (!hasTarget) { for (final call in activeCalls) { VideoCallRemoteEndRegistry.mark(call.id); } @@ -69,7 +148,13 @@ abstract final class VideoCallEndedPushHandler { return; } for (final call in activeCalls) { - if (!callMatchesConversation(call, uuid)) continue; + if (!callMatches( + call: call, + conversationUuid: uuid, + callId: normalizedCallId, + )) { + continue; + } VideoCallRemoteEndRegistry.mark(call.id); try { await FlutterCallkitIncoming.endCall(call.id); diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart index 4d65a705..6767a70c 100644 --- a/lib/features/video_calls/services/video_call_ended_push_payload.dart +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -1,23 +1,58 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; /// Marker for a data-only `video_call_ended` push. class VideoCallEndedPushPayload { - const VideoCallEndedPushPayload({this.conversationUuid}); + const VideoCallEndedPushPayload({ + this.conversationUuid, + this.callId, + this.reason, + this.acceptedDeviceId, + this.acceptedClient, + }); final String? conversationUuid; + final String? callId; + final VideoCallEndReason? reason; + final String? acceptedDeviceId; + final String? acceptedClient; static VideoCallEndedPushPayload? tryParse(Map data) { if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; - final rawUuid = - data['conversationUuid'] ?? - data['conversation_uuid'] ?? - data['uuid']; - final conversationUuid = rawUuid?.toString().trim(); + + final conversationUuid = _string(data, const [ + 'conversationUuid', + 'conversation_uuid', + 'uuid', + ]); + final callId = _string(data, const ['call_id', 'callId']); + final acceptedDeviceId = _string(data, const [ + 'accepted_device_id', + 'acceptedDeviceId', + ]); + final acceptedClient = _string(data, const [ + 'accepted_client', + 'acceptedClient', + ]); + return VideoCallEndedPushPayload( - conversationUuid: - conversationUuid == null || conversationUuid.isEmpty - ? null - : conversationUuid, + conversationUuid: conversationUuid, + callId: callId, + reason: VideoCallEndReason.tryParse( + data['reason'] ?? data['end_reason'] ?? data['endReason'], + ), + acceptedDeviceId: acceptedDeviceId, + acceptedClient: acceptedClient, ); } + + static String? _string(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + return null; + } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 1b11f693..d7e3a5a5 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -58,12 +58,16 @@ abstract final class VideoCallFcmHandler { } log( - 'VideoCallFcmHandler: dismiss incoming call UI, source=$source', + 'VideoCallFcmHandler: dismiss incoming call UI, source=$source ' + 'call_id=${endedPayload.callId ?? '-'} ' + 'reason=${endedPayload.reason?.name ?? '-'}', name: 'VideoCallFcm', ); try { await VideoCallEndedPushHandler.dismissIncomingCallUi( conversationUuid: endedPayload.conversationUuid, + callId: endedPayload.callId, + reason: endedPayload.reason, ); } catch (e, st) { log( 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 1751ebc8..0d377fba 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,7 +1,9 @@ +import 'dart:developer'; 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_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -53,17 +55,23 @@ abstract final class VideoCallIncomingPushPresenter { ? payload.counterpartName!.trim() : 'Итвио'; final ios = isIos ?? Platform.isIOS; + if (VideoCallTombstoneRegistry.contains(payload.callId)) { + log( + 'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}', + name: 'VideoCallIncoming', + ); + return; + } if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) { await (showAndroidIncoming ?? _showAndroidIncoming)(payload); return; } - final callId = ios - ? VideoCallKitId.iosUuid( - conversationUuid: payload.conversationUuid, - messageId: payload.messageId, - ) ?? - payload.conversationUuid - : payload.conversationUuid; + final callId = VideoCallKitId.resolve( + callId: payload.callId, + conversationUuid: payload.conversationUuid, + messageId: payload.messageId, + isIos: ios, + ); if (ios) { final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed; @@ -91,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter { duration: 45000, extra: { 'conversationUuid': payload.conversationUuid, + if (payload.callId != null) 'call_id': payload.callId, if (payload.counterpartName != null) 'counterpartName': payload.counterpartName, if (payload.counterpartAvatarUrl != null) @@ -99,6 +108,7 @@ abstract final class VideoCallIncomingPushPresenter { 'localParticipantName': payload.localParticipantName, if (payload.counterpartIsConsultant != null) 'counterpartIsConsultant': payload.counterpartIsConsultant, + if (payload.messageId != null) 'message_id': payload.messageId, }, callingNotification: const NotificationParams(showNotification: false), missedCallNotification: const NotificationParams( @@ -138,6 +148,7 @@ abstract final class VideoCallIncomingPushPresenter { await _androidPushChannel .invokeMethod('showIncomingCall', { 'conversation_uuid': payload.conversationUuid, + if (payload.callId != null) 'call_id': payload.callId!, if (payload.counterpartName != null) 'caller_name': payload.counterpartName!, if (payload.counterpartAvatarUrl != null) @@ -150,6 +161,8 @@ abstract final class VideoCallIncomingPushPresenter { : 'false', if (payload.messageId != null) 'message_id': payload.messageId.toString(), + if (payload.expiresAt != null) + 'expires_at': payload.expiresAt!.toUtc().toIso8601String(), }); } catch (_) {} } diff --git a/lib/features/video_calls/services/video_call_kit_id.dart b/lib/features/video_calls/services/video_call_kit_id.dart index 16759108..61a11939 100644 --- a/lib/features/video_calls/services/video_call_kit_id.dart +++ b/lib/features/video_calls/services/video_call_kit_id.dart @@ -1,14 +1,33 @@ -/// CallKit on iOS rejects a second incoming call that reuses a UUID. +/// Resolves the CallKit / ConnectionService id for an incoming call. /// -/// 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. +/// Prefer the backend `call_id` (stable for the whole session). Legacy +/// fallback: conversation uuid + message id on iOS, conversation uuid on +/// Android. 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 resolve({ + String? callId, + required String conversationUuid, + int? messageId, + required bool isIos, + }) { + final serverCallId = callId?.trim(); + if (serverCallId != null && serverCallId.isNotEmpty) { + return serverCallId; + } + if (isIos) { + return iosUuid( + conversationUuid: conversationUuid, + messageId: messageId, + ) ?? + conversationUuid; + } + return conversationUuid; + } + static String? iosUuid({ required String conversationUuid, required int? messageId, 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 4a4c2418..68dc7546 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -6,6 +6,8 @@ class VideoCallPushPayload { this.localParticipantName, this.counterpartIsConsultant, this.messageId, + this.callId, + this.expiresAt, }); final String conversationUuid; @@ -14,6 +16,8 @@ class VideoCallPushPayload { final String? localParticipantName; final bool? counterpartIsConsultant; final int? messageId; + final String? callId; + final DateTime? expiresAt; static VideoCallPushPayload? tryParse(Map data) { if (!isIncomingVideoCall(data)) return null; @@ -52,6 +56,8 @@ class VideoCallPushPayload { 'isConsultant', ]), messageId: _messageId(data), + callId: _string(data, const ['call_id', 'callId']), + expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']), ); } @@ -119,6 +125,9 @@ class VideoCallPushPayload { static String? conversationUuidFromData(Map data) => _conversationUuid(data); + static String? callIdFromData(Map data) => + _string(data, const ['call_id', 'callId']); + static String? _conversationUuid(Map data) { final fromData = _string(data, const [ 'conversationUuid', @@ -181,6 +190,17 @@ class VideoCallPushPayload { return null; } + static DateTime? _dateTime(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is DateTime) return value; + if (value is String && value.trim().isNotEmpty) { + return DateTime.tryParse(value.trim()); + } + } + return null; + } + static bool _isTruthy(Object? value) { if (value == null) return false; if (value is bool) return value; diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart index eff4e99b..b87fc03a 100644 --- a/lib/features/video_calls/services/video_call_service.dart +++ b/lib/features/video_calls/services/video_call_service.dart @@ -11,6 +11,7 @@ abstract class VideoCallService { @GET('/api/v2/chat/{conversation}/video-call/token') Future getParticipantToken({ @Path('conversation') required String conversationUuid, + @Query('call_id') String? callId, }); @GET('/api/v2/chat/{conversation}/video-call/admin-token') @@ -29,4 +30,10 @@ abstract class VideoCallService { @Path('conversation') required String conversationUuid, @Body() required Map body, }); + + @POST('/api/v2/chat/{conversation}/video-call/decline') + Future notifyCallDeclined({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); } diff --git a/lib/features/video_calls/services/video_call_tombstone_registry.dart b/lib/features/video_calls/services/video_call_tombstone_registry.dart new file mode 100644 index 00000000..af5ee27d --- /dev/null +++ b/lib/features/video_calls/services/video_call_tombstone_registry.dart @@ -0,0 +1,37 @@ +/// Remembers cancelled / declined call ids so a late `video_call_started` +/// push does not show an already-finished ring. +abstract final class VideoCallTombstoneRegistry { + static const Duration defaultTtl = Duration(minutes: 2); + + static final Map _expiresAtByCallId = {}; + + static void mark( + String? callId, { + DateTime? now, + Duration ttl = defaultTtl, + }) { + final id = _normalize(callId); + if (id == null) return; + final at = now ?? DateTime.now(); + _expiresAtByCallId[id] = at.add(ttl); + } + + static bool contains(String? callId, {DateTime? now}) { + final id = _normalize(callId); + if (id == null) return false; + _prune(now ?? DateTime.now()); + return _expiresAtByCallId.containsKey(id); + } + + static void clear() => _expiresAtByCallId.clear(); + + static void _prune(DateTime now) { + _expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now)); + } + + static String? _normalize(String? callId) { + final id = callId?.trim().toLowerCase(); + if (id == null || id.isEmpty) return null; + return id; + } +} diff --git a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart index f7a271d6..fe89ef8e 100644 --- a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart +++ b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart @@ -52,6 +52,7 @@ void main() { videoCallRepository: mockRepository, foregroundCoordinator: foreground, lifecycleBinding: lifecycleBinding, + resolveHwId: () async => 'test-hw-id-android-01', ); mockRoom = MockRoom(); when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); @@ -94,6 +95,13 @@ void main() { await manager.declineIncomingCall('test-uuid'); verifyNever(mockRepository.notifyCallEnded(any)); + verifyNever( + mockRepository.notifyCallDeclined( + conversationUuid: anyNamed('conversationUuid'), + hwId: anyNamed('hwId'), + callId: anyNamed('callId'), + ), + ); }, ); @@ -112,15 +120,34 @@ void main() { ); test( - 'decline of a call that was never joined notifies the backend', + 'decline of a call that was never joined notifies the backend via /decline', () async { when( - mockRepository.notifyCallEnded(any), - ).thenAnswer((_) async => const VideoCallEndedResponse(sent: true)); + mockRepository.notifyCallDeclined( + conversationUuid: anyNamed('conversationUuid'), + hwId: anyNamed('hwId'), + callId: anyNamed('callId'), + ), + ).thenAnswer( + (_) async => const VideoCallDeclineResponse( + sent: true, + devicesNotified: 1, + ), + ); - await manager.declineIncomingCall('other-uuid'); + await manager.declineIncomingCall( + 'other-uuid', + callId: 'call-uuid-1', + ); - verify(mockRepository.notifyCallEnded('other-uuid')).called(1); + verify( + mockRepository.notifyCallDeclined( + conversationUuid: 'other-uuid', + hwId: 'test-hw-id-android-01', + callId: 'call-uuid-1', + ), + ).called(1); + verifyNever(mockRepository.notifyCallEnded(any)); }, ); diff --git a/test/features/video_calls/repositories/video_call_repository_test.dart b/test/features/video_calls/repositories/video_call_repository_test.dart index 368a839e..60b5bb83 100644 --- a/test/features/video_calls/repositories/video_call_repository_test.dart +++ b/test/features/video_calls/repositories/video_call_repository_test.dart @@ -24,8 +24,10 @@ void main() { final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); final expected = VideoCallTokenResponse.fromJson(json); - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenAnswer((_) async => expected); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenAnswer((_) async => expected); final result = await repository.getParticipantToken(conversationUuid); @@ -33,20 +35,28 @@ void main() { expect(result.token, expected.token); expect(result.wsUrl, expected.wsUrl); expect(result.roomName, expected.roomName); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getParticipantToken - пробрасывает ошибку сервиса', () async { const conversationUuid = 'conv-uuid-broken'; - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenThrow(Exception('No token')); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenThrow(Exception('No token')); expect( () => repository.getParticipantToken(conversationUuid), throwsA(isA()), ); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async { @@ -91,6 +101,60 @@ void main() { expect(captured.single as Map, isEmpty); }); + test('notifyCallEnded - передаёт call_id когда он известен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + await repository.notifyCallEnded(conversationUuid, callId: 'call-1'); + + final captured = verify( + mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured.single as Map; + expect(captured, {'call_id': 'call-1'}); + }); + + test('notifyCallDeclined - передаёт hw_id и call_id', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap( + 'video_calls/video_call_decline_response.json', + ); + final expected = VideoCallDeclineResponse.fromJson(json); + + when(mockService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + final result = await repository.notifyCallDeclined( + conversationUuid: conversationUuid, + hwId: 'device-android-001', + callId: 'call-1', + ); + + expect(result.sent, isTrue); + expect(result.devicesNotified, 1); + + final captured = verify( + mockService.notifyCallDeclined( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured.single as Map; + expect(captured, { + 'hw_id': 'device-android-001', + 'call_id': 'call-1', + }); + }); + test('notifyCallEnded - пробрасывает ошибку сервиса', () async { const conversationUuid = 'conv-uuid-broken'; diff --git a/test/features/video_calls/services/video_call_ended_push_handler_test.dart b/test/features/video_calls/services/video_call_ended_push_handler_test.dart index 90df2f1a..2ec05e08 100644 --- a/test/features/video_calls/services/video_call_ended_push_handler_test.dart +++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart @@ -47,6 +47,36 @@ void main() { }); }); + group('VideoCallEndedPushHandler.callMatches', () { + test('matches by call_id and ignores conversation fallback', () { + const call = CallKitParams( + id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + handle: conversation, + extra: { + 'conversationUuid': conversation, + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + }, + ); + + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ), + isTrue, + ); + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: 'stale-call-id', + ), + isFalse, + ); + }); + }); + group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () { final calls = []; late List> activeCalls; @@ -93,6 +123,40 @@ void main() { expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse); }); + test('ends only the matching call_id', () async { + activeCalls = >[ + { + 'id': 'call-a', + 'handle': conversation, + 'extra': { + 'conversationUuid': conversation, + 'call_id': 'call-a', + }, + }, + { + 'id': 'call-b', + 'handle': conversation, + 'extra': { + 'conversationUuid': conversation, + 'call_id': 'call-b', + }, + }, + ]; + + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: conversation, + callId: 'call-a', + ); + + final ended = calls + .where((call) => call.method == 'endCall') + .map((call) => (call.arguments as Map)['id']) + .toList(); + expect(ended, ['call-a']); + expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue); + expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse); + }); + test( 'without a conversation ends every call and marks all of them', () async { 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 07ad8574..a0dd6a1e 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 @@ -60,6 +60,24 @@ void main() { expect(payload!.conversationUuid, 'abc-123'); }); + test('parses ended video call push with call_id and reason', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + 'reason': 'declined', + 'accepted_device_id': 'device-a', + 'accepted_client': 'android', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); + expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'); + expect(payload.reason?.name, 'declined'); + expect(payload.acceptedDeviceId, 'device-a'); + expect(payload.acceptedClient, 'android'); + }); + test('parses message id for a stable CallKit uuid', () { final payload = VideoCallPushPayload.tryParse({ 'type': 'video_call_started', @@ -77,6 +95,28 @@ void main() { ); }); + test('prefers call_id as CallKit id when present', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_started', + 'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000', + 'message_id': '42', + 'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + 'expires_at': '2026-04-27T14:31:00+00:00', + }); + + expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'); + expect(payload?.expiresAt, isNotNull); + expect( + VideoCallKitId.resolve( + callId: payload!.callId, + conversationUuid: payload.conversationUuid, + messageId: payload.messageId, + isIos: true, + ), + '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ); + }); + test('ignores regular chat push', () { expect( VideoCallPushPayload.tryParse({ diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart index 4d2aa887..93daec33 100644 --- a/test/features/video_calls/services/video_call_service_test.dart +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -21,23 +21,32 @@ void main() { final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); final expected = VideoCallTokenResponse.fromJson(json); - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenAnswer((_) async => expected); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenAnswer((_) async => expected); - final result = await mockService.getParticipantToken(conversationUuid: conversationUuid); + final result = await mockService.getParticipantToken( + conversationUuid: conversationUuid, + ); expect(result, isA()); expect(result.token, expected.token); expect(result.wsUrl, expected.wsUrl); expect(result.roomName, expected.roomName); - verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + verify(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: null, + )).called(1); }); test('getParticipantToken - пробрасывает ошибку', () async { const conversationUuid = 'conv-uuid-error'; - when(mockService.getParticipantToken(conversationUuid: conversationUuid)) - .thenThrow(Exception('Network error')); + when(mockService.getParticipantToken( + conversationUuid: conversationUuid, + callId: anyNamed('callId'), + )).thenThrow(Exception('Network error')); expect( () => mockService.getParticipantToken(conversationUuid: conversationUuid), diff --git a/test/features/video_calls/services/video_call_tombstone_registry_test.dart b/test/features/video_calls/services/video_call_tombstone_registry_test.dart new file mode 100644 index 00000000..f66ac2e5 --- /dev/null +++ b/test/features/video_calls/services/video_call_tombstone_registry_test.dart @@ -0,0 +1,22 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(VideoCallTombstoneRegistry.clear); + + test('marks a call id so a late incoming can be suppressed', () { + const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b'; + final now = DateTime.utc(2026, 10, 21, 10); + + VideoCallTombstoneRegistry.mark(callId, now: now); + + expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue); + expect( + VideoCallTombstoneRegistry.contains( + callId, + now: now.add(const Duration(minutes: 3)), + ), + isFalse, + ); + }); +} diff --git a/test/fixtures/video_calls/video_call_decline_response.json b/test/fixtures/video_calls/video_call_decline_response.json new file mode 100644 index 00000000..783f7127 --- /dev/null +++ b/test/fixtures/video_calls/video_call_decline_response.json @@ -0,0 +1,6 @@ +{ + "sent": true, + "devices_notified": 1, + "call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b", + "status": "declined" +}