From 776d71ccd14b71b95ec23f754500ddeb95d898fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Mon, 15 Jun 2026 13:19:05 +0200 Subject: [PATCH 1/2] call accept race fix --- packages/stream_video/lib/src/call/call.dart | 9 +++++++-- .../lib/src/call/state/mixins/state_lifecycle_mixin.dart | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 4736aad15..f7535deba 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -898,9 +898,14 @@ class Call { } _session?.trace('call.accept', null); + + // Optimistically mark the call as accepted + _stateManager.lifecycleCallAccepted(); + final result = await _coordinatorClient.acceptCall(cid: state.callCid); - if (result is Success) { - _stateManager.lifecycleCallAccepted(); + if (result is Failure) { + // Revert the optimistic acceptance so the user can retry or reject. + _stateManager.lifecycleCallAccepted(accepted: false); } return result; diff --git a/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart index 783af6af0..533987dc6 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart @@ -43,16 +43,16 @@ mixin StateLifecycleMixin on StateNotifier { ); } - void lifecycleCallAccepted() { + void lifecycleCallAccepted({bool accepted = true}) { final status = state.status; - if (status is! CallStatusIncoming || status.acceptedByMe) { + if (status is! CallStatusIncoming || status.acceptedByMe == accepted) { _logger.w( () => '[lifecycleCallAccepted] rejected (invalid status): $status', ); return; } state = state.copyWith( - status: CallStatus.incoming(acceptedByMe: true), + status: CallStatus.incoming(acceptedByMe: accepted), ); } From 6272a7539af37ba7a45148ae9d74d8b2c2da2552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Wed, 24 Jun 2026 09:08:37 +0200 Subject: [PATCH 2/2] changelog + tests --- packages/stream_video/CHANGELOG.md | 6 + .../test/src/call/call_accept_test.dart | 219 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/stream_video/test/src/call/call_accept_test.dart diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 7c959bbc2..6c3340d03 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### 🐞 Fixed + +- Fixed incoming calls being locally rejected after accept when the coordinator WebSocket event arrived before the HTTP response ([#1254](https://github.com/GetStream/stream-video-flutter/issues/1254)). + ## 1.4.0 Each call now owns an isolated native `PeerConnectionFactory`. This fixes cross-call audio interference, sibling-call microphone capture loss, and noise cancellation failing to engage during lobby preview. diff --git a/packages/stream_video/test/src/call/call_accept_test.dart b/packages/stream_video/test/src/call/call_accept_test.dart new file mode 100644 index 000000000..e3594188e --- /dev/null +++ b/packages/stream_video/test/src/call/call_accept_test.dart @@ -0,0 +1,219 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/src/shared_emitter.dart'; +import 'package:stream_video/stream_video.dart'; + +import '../../test_helpers.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + + registerFallbackValue( + StreamCallCid.from( + type: StreamCallType.defaultType(), + id: 'fallback-call-id', + ), + ); + }); + + group('Call.accept optimistic acceptance', () { + late StreamVideo streamVideo; + late MockCoordinatorClient mockCoordinatorClient; + + setUp(() { + mockCoordinatorClient = MockCoordinatorClient(); + + when( + () => mockCoordinatorClient.events, + ).thenReturn(MutableSharedEmitterImpl()); + + streamVideo = StreamVideo.create( + 'test-api-key', + user: User.regular(userId: 'test-user', name: 'Test User'), + userToken: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiSm9obiBEb2UifQ.hrrtiYCtfs2cowE2sx2dypxoXhsEE8pQl-V6Nq4i8qU', + options: StreamVideoOptions( + allowMultipleActiveCalls: true, + autoConnect: false, + ), + ); + }); + + tearDown(() async { + await StreamVideo.reset(); + }); + + Call createIncomingCall() { + final ringingCallCid = StreamCallCid.from( + id: 'ringing-call', + type: StreamCallType.defaultType(), + ); + + final ringingData = CallRingingData( + callCid: ringingCallCid, + ringing: true, + metadata: CallMetadata( + cid: ringingCallCid, + details: createTestCallDetails(createdByUserId: 'other-user'), + settings: const CallSettings(), + session: const CallSessionData(), + users: const {}, + members: const {}, + ), + ); + + return Call.fromRinging( + data: ringingData, + coordinatorClient: mockCoordinatorClient, + streamVideo: streamVideo, + networkMonitor: InternetConnection.createInstance(), + ); + } + + test( + 'marks call as accepted before coordinator accept completes', + () async { + final acceptCompleter = Completer>(); + when( + () => mockCoordinatorClient.acceptCall(cid: any(named: 'cid')), + ).thenAnswer((_) => acceptCompleter.future); + + final call = createIncomingCall(); + final acceptFuture = call.accept(); + + final statusWhilePending = + call.state.value.status as CallStatusIncoming; + expect(statusWhilePending.acceptedByMe, isTrue); + + acceptCompleter.complete(const Result.success(none)); + final result = await acceptFuture; + + expect(result.isSuccess, isTrue); + final statusAfterSuccess = + call.state.value.status as CallStatusIncoming; + expect(statusAfterSuccess.acceptedByMe, isTrue); + }, + ); + + test('reverts acceptedByMe when coordinator accept fails', () async { + when( + () => mockCoordinatorClient.acceptCall(cid: any(named: 'cid')), + ).thenAnswer((_) async => Result.error('network error')); + + final call = createIncomingCall(); + final result = await call.accept(); + + expect(result.isFailure, isTrue); + final status = call.state.value.status as CallStatusIncoming; + expect(status.acceptedByMe, isFalse); + }); + + test('allows retry after failed accept', () async { + var acceptAttempts = 0; + when( + () => mockCoordinatorClient.acceptCall(cid: any(named: 'cid')), + ).thenAnswer((_) async { + acceptAttempts++; + if (acceptAttempts == 1) { + return Result.error('network error'); + } + return const Result.success(none); + }); + + final call = createIncomingCall(); + + final firstResult = await call.accept(); + expect(firstResult.isFailure, isTrue); + expect( + (call.state.value.status as CallStatusIncoming).acceptedByMe, + isFalse, + ); + + final secondResult = await call.accept(); + expect(secondResult.isSuccess, isTrue); + expect( + (call.state.value.status as CallStatusIncoming).acceptedByMe, + isTrue, + ); + expect(acceptAttempts, 2); + }); + }); + + group('CallStateNotifier.lifecycleCallAccepted', () { + late CallStateNotifier stateNotifier; + + setUp(() { + stateNotifier = CallStateNotifier( + CallState( + callCid: StreamCallCid.from( + type: StreamCallType.defaultType(), + id: 'test-call', + ), + currentUserId: 'current-user', + preferences: DefaultCallPreferences(), + ).copyWith( + status: CallStatus.incoming(acceptedByMe: false), + ), + ); + }); + + tearDown(() { + stateNotifier.dispose(); + }); + + test('marks incoming call as accepted', () { + stateNotifier.lifecycleCallAccepted(); + + final status = stateNotifier.state.status as CallStatusIncoming; + expect(status.acceptedByMe, isTrue); + }); + + test('reverts accepted call to not accepted', () { + stateNotifier.state = stateNotifier.state.copyWith( + status: CallStatus.incoming(acceptedByMe: true), + ); + + stateNotifier.lifecycleCallAccepted(accepted: false); + + final status = stateNotifier.state.status as CallStatusIncoming; + expect(status.acceptedByMe, isFalse); + }); + + test('ignores duplicate accept transition', () { + stateNotifier.lifecycleCallAccepted(); + final acceptedState = stateNotifier.state; + + stateNotifier.lifecycleCallAccepted(); + + expect(stateNotifier.state, same(acceptedState)); + }); + + test('ignores duplicate revert transition', () { + stateNotifier.lifecycleCallAccepted(accepted: false); + + expect(stateNotifier.state.status, isA()); + expect( + (stateNotifier.state.status as CallStatusIncoming).acceptedByMe, + isFalse, + ); + }); + + test('ignores transition when call is not incoming', () { + stateNotifier.state = stateNotifier.state.copyWith( + status: CallStatus.connected(), + ); + final connectedState = stateNotifier.state; + + stateNotifier.lifecycleCallAccepted(); + + expect(stateNotifier.state, same(connectedState)); + }); + }); +}