From 4ef174ebde2c9b4a7fa13767178597d0ade62b8a Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 15 Oct 2026 15:21:38 +0300 Subject: [PATCH] feat(video_calls): repeated calls after call termination fix --- .../managers/video_call_manager.dart | 14 ++++++++- .../video_call_session_cubit.dart | 17 +++++++++-- .../services/video_call_peer_departure.dart | 12 ++++++++ .../video_call_manager_lifecycle_test.dart | 5 ++++ .../video_call_peer_departure_test.dart | 30 +++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 5f10d7af..d053eceb 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -406,7 +406,11 @@ class VideoCallManager { Future endCall() async { final uuid = _snapshot.conversationUuid; - final shouldNotifyBackend = !_callEndedNotified && uuid.isNotEmpty; + final shouldNotifyBackend = shouldNotifyCallEnded( + alreadyNotified: _callEndedNotified, + hasConversation: uuid.isNotEmpty, + peerStillInRoom: _roomHasRemoteUser(), + ); if (uuid.isNotEmpty) { _callEndedNotified = true; } @@ -1099,6 +1103,14 @@ class VideoCallManager { return false; } + bool _roomHasRemoteUser() { + final room = _room; + if (room == null) return false; + return videoCallRoomStillHasRemoteUser( + room.remoteParticipants.values.map((participant) => participant.identity), + ); + } + bool get _canHandleCallAudio => _callStageEntered && !_liveKitDisposed && diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 5387e500..d9bfa8d9 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -541,9 +541,20 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { - await _screenWakeLock.release(); - await _systemPipCoordinator.disarm(); - await _videoCallManager.endCall(); + _suppressDisposeSideEffects = true; + try { + await _screenWakeLock.release(); + await _systemPipCoordinator.disarm(); + await _videoCallManager.endCall(); + if (isClosed) return; + // The idle snapshot is ignored while the call screen is still visible, + // so hang up has to clear the session before the route pops. Otherwise + // dispose keeps the call and the next token request rings both users. + emit(const VideoCallSessionState()); + await _restoreSystemUiAfterCall(); + } finally { + _suppressDisposeSideEffects = false; + } } void expandLocal() { diff --git a/lib/features/video_calls/services/video_call_peer_departure.dart b/lib/features/video_calls/services/video_call_peer_departure.dart index 102d9043..f6d28433 100644 --- a/lib/features/video_calls/services/video_call_peer_departure.dart +++ b/lib/features/video_calls/services/video_call_peer_departure.dart @@ -18,6 +18,18 @@ bool shouldForceEndCallAfterPeerLeft({ return !videoCallRoomStillHasRemoteUser(remoteIdentities); } +/// The participant who taps hang up must not report the end while the other +/// person is still in the room. That report and the remaining client's report +/// both see an empty room and each one rings the other user again. +bool shouldNotifyCallEnded({ + required bool alreadyNotified, + required bool hasConversation, + required bool peerStillInRoom, +}) { + if (!hasConversation || alreadyNotified || peerStillInRoom) return false; + return true; +} + bool shouldForceEndCallAfterRoomDisconnect({ required bool callStageEntered, required bool sessionConnected, 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 36fb87b0..df5ea33b 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 @@ -2,6 +2,8 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'dart:collection'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -53,6 +55,9 @@ void main() { mockRoom = MockRoom(); when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); when(mockRoom.localParticipant).thenReturn(null); + when( + mockRoom.remoteParticipants, + ).thenReturn(UnmodifiableMapView({})); }); tearDown(() { diff --git a/test/features/video_calls/services/video_call_peer_departure_test.dart b/test/features/video_calls/services/video_call_peer_departure_test.dart index 7ecb1799..a8ec1136 100644 --- a/test/features/video_calls/services/video_call_peer_departure_test.dart +++ b/test/features/video_calls/services/video_call_peer_departure_test.dart @@ -66,6 +66,36 @@ void main() { ); }); + test('does not report the end while the other user is still in the room', () { + expect( + shouldNotifyCallEnded( + alreadyNotified: false, + hasConversation: true, + peerStillInRoom: true, + ), + isFalse, + ); + }); + + test('reports the end when this client is the last participant', () { + expect( + shouldNotifyCallEnded( + alreadyNotified: false, + hasConversation: true, + peerStillInRoom: false, + ), + isTrue, + ); + expect( + shouldNotifyCallEnded( + alreadyNotified: true, + hasConversation: true, + peerStillInRoom: false, + ), + isFalse, + ); + }); + test('keeps a connection-loss screen when the link drops', () { expect( shouldForceEndCallAfterRoomDisconnect(