From 8a00f61691315e9c48058d6048fcdb7b33b2c27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Thu, 4 Jun 2026 16:26:11 +0200 Subject: [PATCH 1/8] ongoing call ringing fixes --- .../lib/screens/call_participants_list.dart | 363 +++++++++++++++--- .../state/mixins/state_coordinator_mixin.dart | 83 ++-- .../stream_video/lib/src/models/models.dart | 1 + .../stream_video/lib/src/stream_video.dart | 18 +- 4 files changed, 368 insertions(+), 97 deletions(-) diff --git a/dogfooding/lib/screens/call_participants_list.dart b/dogfooding/lib/screens/call_participants_list.dart index 160e5dc76..3e6ccc096 100644 --- a/dogfooding/lib/screens/call_participants_list.dart +++ b/dogfooding/lib/screens/call_participants_list.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; -import '../widgets/share_call_card.dart'; +import '../core/repos/app_preferences.dart'; +import '../di/injector.dart'; +import '../widgets/stream_button.dart'; class CallParticipantsList extends StatelessWidget { const CallParticipantsList({super.key, required this.call}); @@ -14,18 +17,25 @@ class CallParticipantsList extends StatelessWidget { final textTheme = streamVideoTheme.textTheme; return StreamBuilder( + initialData: call.state.value, stream: call.state.asStream(), builder: (context, snapshot) { final callState = snapshot.requireData; final participants = callState.callParticipants; + final participantIds = participants.map((p) => p.userId).toSet(); + final membersNotInCall = callState.callMembers + .where((member) => !participantIds.contains(member.userId)) + .toList(); + return Scaffold( appBar: AppBar( title: Text( - 'Participants (${callState.callParticipants.length})', + 'Participants (${participants.length})', style: textTheme.title3.apply(color: Colors.white), ), centerTitle: true, + foregroundColor: Colors.white, backgroundColor: Theme.of(context).scaffoldBackgroundColor, actions: [ IconButton( @@ -39,70 +49,311 @@ class CallParticipantsList extends StatelessWidget { body: Column( children: [ Expanded( - child: ListView.builder( + child: ListView( padding: const EdgeInsets.only(bottom: 16), - itemBuilder: (context, index) { - final participant = participants[index]; - - return Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - StreamUserAvatarTheme( - data: streamVideoTheme.userAvatarTheme, - child: StreamUserAvatar( - user: UserInfo( - id: participant.userId, - name: participant.name.ifEmpty( - () => participant.userId, - ), - image: participant.image, - ), - ), + children: [ + for (final participant in participants) + _ParticipantTile( + participant: participant, + theme: streamVideoTheme, + ), + if (membersNotInCall.isNotEmpty) ...[ + const Divider(height: 32), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'Members not in call (${membersNotInCall.length})', + style: textTheme.title3, + textAlign: TextAlign.center, + ), + ), + for (final member in membersNotInCall) + _MemberTile( + member: member, + theme: streamVideoTheme, + onRing: () => _ringMember(context, member), + ), + ], + ], + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: StreamButton.tertiary( + icon: const Icon( + Icons.link, + color: Colors.white, ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - participant.name, - overflow: TextOverflow.ellipsis, - ), - Text( - participant.roles.join(', '), - style: textTheme.footnoteItalic, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), + label: 'Share link', + onPressed: () => _shareLink(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: StreamButton.active( + icon: const Icon( + Icons.person_add_alt_1, + color: Colors.white, ), - if (participant.isAudioEnabled) - const Icon(Icons.mic_rounded) - else - const Icon(Icons.mic_off_rounded), - const SizedBox(width: 8), - if (participant.isVideoEnabled) - const Icon(Icons.videocam_rounded) - else - const Icon(Icons.videocam_off_rounded), - ], + label: 'Add member', + onPressed: () => _showAddMemberDialog(context), + ), ), - ); - }, - // separatorBuilder: (context, index) => const Divider(), - itemCount: participants.length, + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + Future _shareLink(BuildContext context) async { + final appPreferences = locator.get(); + final callUrl = appPreferences.environment.getJoinUrl(callId: call.id); + + if (callUrl == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No shareable link for this call')), + ); + return; + } + + final box = context.findRenderObject() as RenderBox?; + final origin = box != null + ? box.localToGlobal(Offset.zero) & box.size + : null; + + await SharePlus.instance.share( + ShareParams( + uri: Uri.parse(callUrl), + sharePositionOrigin: origin, + ), + ); + } + + Future _ringMember( + BuildContext context, + CallMemberState member, + ) async { + final messenger = ScaffoldMessenger.of(context); + final result = await call.ring(userIds: [member.userId]); + + result.fold( + success: (_) { + messenger.showSnackBar( + SnackBar(content: Text('Ringing ${member.name}...')), + ); + }, + failure: (failure) { + messenger.showSnackBar( + SnackBar( + content: Text( + 'Failed to ring ${member.name}: ${failure.error.message}', + ), + ), + ); + }, + ); + } + + Future _showAddMemberDialog(BuildContext context) async { + final controller = TextEditingController(); + final theme = Theme.of(context); + + final userId = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: theme.scaffoldBackgroundColor, + title: Text( + 'Enter the ID of the member you want to add', + style: theme.textTheme.bodyLarge, + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'User ID', + hintStyle: TextStyle(color: Colors.white30), + ), + onSubmitted: (value) => Navigator.of(context).pop(value.trim()), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 150, + child: StreamButton.active( + label: 'Add', + icon: const Icon( + Icons.person_add_alt_1, + color: Colors.white, + ), + onPressed: () => + Navigator.of(context).pop(controller.text.trim()), + ), ), ), - ShareCallParticipantsCard(callId: call.id), ], ), ); }, ); + + if (userId == null || userId.isEmpty) return; + if (!context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final result = await call.addMembers([UserInfo(id: userId)]); + + result.fold( + success: (_) { + messenger.showSnackBar( + SnackBar(content: Text('Added $userId to the call')), + ); + }, + failure: (failure) { + messenger.showSnackBar( + SnackBar( + content: Text('Failed to add $userId: ${failure.error.message}'), + ), + ); + }, + ); + } +} + +class _ParticipantTile extends StatelessWidget { + const _ParticipantTile({ + required this.participant, + required this.theme, + }); + + final CallParticipantState participant; + final StreamVideoTheme theme; + + @override + Widget build(BuildContext context) { + final textTheme = theme.textTheme; + + return Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + StreamUserAvatarTheme( + data: theme.userAvatarTheme, + child: StreamUserAvatar( + user: UserInfo( + id: participant.userId, + name: participant.name.ifEmpty(() => participant.userId), + image: participant.image, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + participant.name, + overflow: TextOverflow.ellipsis, + ), + Text( + participant.roles.join(', '), + style: textTheme.footnoteItalic, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + if (participant.isAudioEnabled) + const Icon(Icons.mic_rounded) + else + const Icon(Icons.mic_off_rounded), + const SizedBox(width: 8), + if (participant.isVideoEnabled) + const Icon(Icons.videocam_rounded) + else + const Icon(Icons.videocam_off_rounded), + ], + ), + ); + } +} + +class _MemberTile extends StatelessWidget { + const _MemberTile({ + required this.member, + required this.theme, + required this.onRing, + }); + + final CallMemberState member; + final StreamVideoTheme theme; + final VoidCallback onRing; + + @override + Widget build(BuildContext context) { + final textTheme = theme.textTheme; + + return Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + StreamUserAvatarTheme( + data: theme.userAvatarTheme, + child: StreamUserAvatar( + user: UserInfo( + id: member.userId, + name: member.name.ifEmpty(() => member.userId), + image: member.image, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member.name.ifEmpty(() => member.userId), + overflow: TextOverflow.ellipsis, + ), + Text( + member.roles.join(', '), + style: textTheme.footnoteItalic, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Colors.white), + icon: const Icon(Icons.notifications_active_outlined), + label: const Text('Ring'), + onPressed: onRing, + ), + ], + ), + ); } } diff --git a/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart index bb44ce94e..b5e5fec4d 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart @@ -93,47 +93,52 @@ mixin StateCoordinatorMixin on StateNotifier { } }).toList(); - if (state.createdByMe) { - final everyoneElseRejected = - state.otherParticipants.isEmpty && - state.callMembers - .where((m) => m.userId != state.currentUserId) - .every((m) => rejectedBy.keys.contains(m.userId)); - - if (everyoneElseRejected) { - _logger.d( - () => '[coordinatorCallRejected] everyone rejected, disconnecting', - ); - state = state.copyWith( - status: CallStatus.disconnected( - DisconnectReason.rejected( - byUserId: event.rejectedByUserId, - reason: CallRejectReason.allOtherParticipantsRejected(), + // Auto-disconnect on rejection only applies to the ringing flow (call + // created with `ringing: true`). Ringing users mid-call via `Call.ring()` + // must not tear down the already established call when everyone declines. + if (state.isRingingFlow) { + if (state.createdByMe) { + final everyoneElseRejected = + state.otherParticipants.isEmpty && + state.callMembers + .where((m) => m.userId != state.currentUserId) + .every((m) => rejectedBy.keys.contains(m.userId)); + + if (everyoneElseRejected) { + _logger.d( + () => '[coordinatorCallRejected] everyone rejected, disconnecting', + ); + state = state.copyWith( + status: CallStatus.disconnected( + DisconnectReason.rejected( + byUserId: event.rejectedByUserId, + reason: CallRejectReason.allOtherParticipantsRejected(), + ), ), - ), - sessionId: '', - callParticipants: const [], - callMembers: members, - ); - return; - } - } else { - if (rejectedBy.keys.contains(state.createdByUserId)) { - _logger.d( - () => '[coordinatorCallRejected] creator rejected, disconnecting', - ); - state = state.copyWith( - status: CallStatus.disconnected( - DisconnectReason.rejected( - byUserId: event.rejectedByUserId, - reason: CallRejectReason.creatorRejected(), + sessionId: '', + callParticipants: const [], + callMembers: members, + ); + return; + } + } else { + if (rejectedBy.keys.contains(state.createdByUserId)) { + _logger.d( + () => '[coordinatorCallRejected] creator rejected, disconnecting', + ); + state = state.copyWith( + status: CallStatus.disconnected( + DisconnectReason.rejected( + byUserId: event.rejectedByUserId, + reason: CallRejectReason.creatorRejected(), + ), ), - ), - sessionId: '', - callParticipants: const [], - callMembers: members, - ); - return; + sessionId: '', + callParticipants: const [], + callMembers: members, + ); + return; + } } } diff --git a/packages/stream_video/lib/src/models/models.dart b/packages/stream_video/lib/src/models/models.dart index 4cb8d61e7..9b8793d81 100644 --- a/packages/stream_video/lib/src/models/models.dart +++ b/packages/stream_video/lib/src/models/models.dart @@ -5,6 +5,7 @@ export 'call_created_data.dart'; export 'call_credentials.dart'; export 'call_egress.dart'; export 'call_joined_data.dart'; +export 'call_member_state.dart'; export 'call_metadata.dart'; export 'call_participant_state.dart'; export 'call_permission.dart'; diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index 291b57650..4bac7ae7d 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -790,11 +790,16 @@ class StreamVideo extends Disposable { final acceptResult = await call.accept(); if (acceptResult.isFailure) { - _logger.d( + _logger.w( () => '[consumeAndAcceptActiveCall] error accepting call: ' '${acceptResult.getErrorOrNull()}', ); + + // The native UI (e.g. CallKit) has already answered the call at this + // point. End it there so the user isn't left on an answered call that + // never joins. + await pushNotificationManager?.endCallByCid(call.callCid.value); return false; } @@ -930,7 +935,16 @@ class StreamVideo extends Disposable { final acceptResult = await callToJoin.accept(); if (acceptResult.isFailure) { - _logger.d(() => '[onCallAccept] error accepting call: $callToJoin'); + _logger.w( + () => + '[onCallAccept] error accepting call ($callToJoin): ' + '${acceptResult.getErrorOrNull()}', + ); + + // The native UI (e.g. CallKit) has already answered the call at this + // point. End it there so the user isn't left on an answered call that + // never joins. + await pushNotificationManager?.endCallByCid(cid); return; } From 2ef6cebec7c9e782f4184808c9dce77eb6b1f8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Thu, 4 Jun 2026 16:29:04 +0200 Subject: [PATCH 2/8] changelog --- packages/stream_video/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 7c959bbc2..4befd2269 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +### 🐞 Fixed + +- Fixed an issue where ringing a member during an ongoing call could prematurely end the call if they declined. +- Fixed an issue where a failed call accept attempt left the CallKit call active on iOS. + ## 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. From e7a95afb434e4a7d41e17c29b5687bab734c56eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Fri, 5 Jun 2026 12:55:35 +0200 Subject: [PATCH 3/8] unit tests --- .../call/call_coordinator_events_test.dart | 76 +++++ .../test/src/call/call_ring_test.dart | 260 ++++++++++++++++++ .../src/call/fixtures/call_test_helpers.dart | 3 + 3 files changed, 339 insertions(+) create mode 100644 packages/stream_video/test/src/call/call_ring_test.dart diff --git a/packages/stream_video/test/src/call/call_coordinator_events_test.dart b/packages/stream_video/test/src/call/call_coordinator_events_test.dart index 5b05cfcfc..b226e25f6 100644 --- a/packages/stream_video/test/src/call/call_coordinator_events_test.dart +++ b/packages/stream_video/test/src/call/call_coordinator_events_test.dart @@ -1493,6 +1493,7 @@ void main() { stateManager: stateManager, ); await call.getOrCreate( + ringing: true, memberIds: members.map((m) => m.userId).toList(), ); @@ -1561,6 +1562,7 @@ void main() { stateManager: stateManager, ); await call.getOrCreate( + ringing: true, memberIds: members.map((m) => m.userId).toList(), ); @@ -1626,6 +1628,7 @@ void main() { stateManager: stateManager, ); await call.getOrCreate( + ringing: true, memberIds: members.map((m) => m.userId).toList(), ); @@ -1729,6 +1732,79 @@ void main() { }, ); + test( + 'call rejected event - should not disconnect when not in ringing flow ' + '(member rung mid-call)', + () async { + final coordinatorEvents = MutableSharedEmitterImpl(); + + final currentUser = SampleCallData.defaultCallUser; + final user1 = SampleCallData.testCallUser1; + + final members = [ + SampleCallData.createCallMember(userId: currentUser.id), + SampleCallData.createCallMember(userId: user1.id), + ]; + + final coordinatorClient = setupMockCoordinatorClient( + events: coordinatorEvents, + getOrCreateCallResult: SampleCallData.createCallReceivedOrCreatedData( + members: { + currentUser.id: members[0], + user1.id: members[1], + }, + createdByUser: currentUser, + ), + ); + + final initialState = createActiveCallState( + currentByUser: currentUser, + ); + final stateManager = CallStateNotifier(initialState); + + final call = createTestCall( + coordinatorClient: coordinatorClient, + stateManager: stateManager, + ); + + // Non-ringing flow: the call is established normally and members are + // rung mid-call via Call.ring(), so a rejection must not tear it down. + await call.getOrCreate( + memberIds: members.map((m) => m.userId).toList(), + ); + + expect(call.state.value.isRingingFlow, isFalse); + expect(call.state.value.status, isA()); + + // Even though the creator is the current user and the only other member + // rejects, the call should stay active outside the ringing flow. + final rejectedTime = DateTime.now(); + coordinatorEvents.emit( + CoordinatorCallRejectedEvent( + callCid: call.callCid, + user: currentUser, + rejectedBy: user1, + createdAt: rejectedTime, + metadata: SampleCallData.createCallMetadata( + rejectedBy: {user1.id: rejectedTime}, + createdByUser: currentUser, + ), + ), + ); + + await Future.delayed(Duration.zero); + + // Call stays active; only the member is marked as rejected. + expect(call.state.value.status, isA()); + expect( + call.state.value.callMembers + .firstWhereOrNull((m) => m.userId == user1.id) + ?.callRejectedAt, + equals(rejectedTime), + ); + }, + ); + test( 'call accepted event - should update accepted member with callAcceptedAt timestamp and status', () async { diff --git a/packages/stream_video/test/src/call/call_ring_test.dart b/packages/stream_video/test/src/call/call_ring_test.dart new file mode 100644 index 000000000..17a63ea55 --- /dev/null +++ b/packages/stream_video/test/src/call/call_ring_test.dart @@ -0,0 +1,260 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/open_api/video/coordinator/api.dart' as open; +import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/src/errors/video_error.dart'; +import 'package:stream_video/src/shared_emitter.dart'; +import 'package:stream_video/stream_video.dart'; + +import '../../test_helpers.dart'; +import 'fixtures/call_test_helpers.dart'; +import 'fixtures/data.dart'; + +void main() { + setUpAll(() { + registerMockFallbackValues(); + registerFallbackValue([]); + registerFallbackValue([]); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + /// Sets up an ongoing (connected) call started by the current user, with the + /// current user already a member of the call. + Future<({ + Call call, + MutableSharedEmitterImpl events, + MockCoordinatorClient coordinatorClient, + })> + setupOngoingCall({ + Map? members, + }) async { + final coordinatorEvents = MutableSharedEmitterImpl(); + + final currentUser = SampleCallData.defaultCallUser; + final effectiveMembers = + members ?? + { + currentUser.id: SampleCallData.createCallMember( + userId: currentUser.id, + ), + }; + + final coordinatorClient = setupMockCoordinatorClient( + events: coordinatorEvents, + getOrCreateCallResult: SampleCallData.createCallReceivedOrCreatedData( + members: effectiveMembers, + createdByUser: currentUser, + ), + ); + + final stateManager = CallStateNotifier( + createActiveCallState( + currentByUser: currentUser, + status: CallStatus.connected(), + ), + ); + + final call = createTestCall( + coordinatorClient: coordinatorClient, + stateManager: stateManager, + ); + await call.getOrCreate(); + + return ( + call: call, + events: coordinatorEvents, + coordinatorClient: coordinatorClient, + ); + } + + void stubRingCall( + MockCoordinatorClient client, { + Result>? result, + }) { + when( + () => client.ringCall( + callCid: any(named: 'callCid'), + membersIds: any(named: 'membersIds'), + video: any(named: 'video'), + ), + ).thenAnswer( + (_) async => result ?? const Result.success(['user1']), + ); + } + + void stubAddMembers( + MockCoordinatorClient client, { + Result? result, + }) { + when( + () => client.addMembers( + callCid: any(named: 'callCid'), + members: any(named: 'members'), + ), + ).thenAnswer( + (_) async => result ?? const Result.success(none), + ); + } + + group('Ongoing call ringing', () { + test('rings the specified members of an ongoing call', () async { + final setup = await setupOngoingCall(); + stubRingCall( + setup.coordinatorClient, + result: const Result.success(['user1']), + ); + + final result = await setup.call.ring(userIds: ['user1']); + + expect(result.isSuccess, isTrue); + expect((result as Success>).data, equals(['user1'])); + + verify( + () => setup.coordinatorClient.ringCall( + callCid: setup.call.callCid, + membersIds: ['user1'], + video: false, + ), + ).called(1); + }); + + test('rings all members not in the call when no ids are provided', () async { + final setup = await setupOngoingCall(); + stubRingCall( + setup.coordinatorClient, + result: const Result.success(['user1', 'user2']), + ); + + final result = await setup.call.ring(); + + expect(result.isSuccess, isTrue); + verify( + () => setup.coordinatorClient.ringCall( + callCid: setup.call.callCid, + membersIds: const [], + video: false, + ), + ).called(1); + }); + + test('forwards the video flag when ringing', () async { + final setup = await setupOngoingCall(); + stubRingCall(setup.coordinatorClient); + + await setup.call.ring(userIds: ['user1'], video: true); + + verify( + () => setup.coordinatorClient.ringCall( + callCid: setup.call.callCid, + membersIds: ['user1'], + video: true, + ), + ).called(1); + }); + + test('propagates failure from the coordinator when ringing fails', () async { + final setup = await setupOngoingCall(); + stubRingCall( + setup.coordinatorClient, + result: const Result.failure(VideoError(message: 'ring failed')), + ); + + final result = await setup.call.ring(userIds: ['user1']); + + expect(result.isFailure, isTrue); + expect((result as Failure).error.message, equals('ring failed')); + }); + + test('adds a member to the ongoing call via the coordinator', () async { + final setup = await setupOngoingCall(); + stubAddMembers(setup.coordinatorClient); + + final result = await setup.call.addMembers([ + const UserInfo(id: 'user2', role: 'user'), + ]); + + expect(result.isSuccess, isTrue); + + final captured = verify( + () => setup.coordinatorClient.addMembers( + callCid: setup.call.callCid, + members: captureAny(named: 'members'), + ), + ).captured; + + final members = + (captured.single as Iterable).toList(); + expect(members, hasLength(1)); + expect(members.first.userId, equals('user2')); + expect(members.first.role, equals('user')); + }); + + test( + 'add member then ring: state updates after member added event, ' + 'then the new member can be rung', + () async { + final currentUser = SampleCallData.defaultCallUser; + final newUser = SampleCallData.testCallUser2; + + final setup = await setupOngoingCall(); + stubAddMembers(setup.coordinatorClient); + stubRingCall( + setup.coordinatorClient, + result: Result.success([newUser.id]), + ); + + // Initially only the current user is a member. + expect( + setup.call.state.value.callMembers.map((m) => m.userId), + equals([currentUser.id]), + ); + + // Add the new member. + final addResult = await setup.call.addMembers([ + UserInfo(id: newUser.id), + ]); + expect(addResult.isSuccess, isTrue); + + // The coordinator emits a member added event which should update state. + final newMember = SampleCallData.createCallMember(userId: newUser.id); + setup.events.emit( + CoordinatorCallMemberAddedEvent( + callCid: setup.call.callCid, + members: [newMember], + metadata: SampleCallData.createCallMetadata( + members: {newMember.userId: newMember}, + ), + createdAt: DateTime.now(), + ), + ); + await Future.delayed(Duration.zero); + + // The newly added member is now part of the call members and is not yet + // a participant (not in the call). + final addedMember = setup.call.state.value.callMembers + .firstWhereOrNull((m) => m.userId == newUser.id); + expect(addedMember, isNotNull); + expect( + setup.call.state.value.callParticipants + .any((p) => p.userId == newUser.id), + isFalse, + ); + + // Now ring the newly added member. + final ringResult = await setup.call.ring(userIds: [newUser.id]); + expect(ringResult.isSuccess, isTrue); + + verify( + () => setup.coordinatorClient.ringCall( + callCid: setup.call.callCid, + membersIds: [newUser.id], + video: false, + ), + ).called(1); + }, + ); + }); +} diff --git a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart index 01a387f93..7ca70f542 100644 --- a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart +++ b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart @@ -161,6 +161,9 @@ MockClientState setupMockClientState() { () => clientState.removeActiveCall(any()), ).thenAnswer((_) => Future.value()); when(() => clientState.outgoingCall).thenAnswer((_) => outgoingCallEmitter); + when( + () => clientState.setOutgoingCall(any()), + ).thenAnswer((_) => Future.value()); return clientState; } From 814fec6e0a6fb994d745c4ae61c339613f00cdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Fri, 5 Jun 2026 12:56:06 +0200 Subject: [PATCH 4/8] cleanup --- .../lib/src/call/state/mixins/state_lifecycle_mixin.dart | 1 - .../stream_video/test/src/call/fixtures/call_test_helpers.dart | 1 - 2 files changed, 2 deletions(-) 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..5cf0d4456 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 @@ -4,7 +4,6 @@ import 'package:state_notifier/state_notifier.dart'; import '../../../call_state.dart'; import '../../../logger/impl/tagged_logger.dart'; import '../../../logger/stream_logger.dart'; -import '../../../models/call_member_state.dart'; import '../../../models/call_received_data.dart'; import '../../../models/models.dart'; import '../../../sfu/data/models/sfu_error.dart'; diff --git a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart index 7ca70f542..10d480cfc 100644 --- a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart +++ b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart @@ -11,7 +11,6 @@ import 'package:stream_video/src/call/session/call_session_factory.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; import 'package:stream_video/src/call/stats/tracer.dart'; import 'package:stream_video/src/core/client_state.dart'; -import 'package:stream_video/src/models/call_member_state.dart'; import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; import 'package:stream_video/src/sfu/data/models/sfu_call_state.dart'; import 'package:stream_video/src/sfu/data/models/sfu_participant.dart'; From 5be02c51edadbdaed2e86fc87951578a131c3b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Fri, 5 Jun 2026 12:57:33 +0200 Subject: [PATCH 5/8] cleanup --- packages/stream_video/lib/src/call_state.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_video/lib/src/call_state.dart b/packages/stream_video/lib/src/call_state.dart index db181d3db..c23ae2141 100644 --- a/packages/stream_video/lib/src/call_state.dart +++ b/packages/stream_video/lib/src/call_state.dart @@ -3,7 +3,6 @@ import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; import 'call/call_type.dart'; -import 'models/call_member_state.dart'; import 'models/models.dart'; import 'sfu/data/models/sfu_audio_bitrate.dart'; import 'webrtc/rtc_media_device/rtc_media_device.dart'; From 89b9bbfe937a382854fb10996d42a2d9d1b6af47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Fri, 5 Jun 2026 13:03:40 +0200 Subject: [PATCH 6/8] tweaks --- .../lib/screens/call_participants_list.dart | 18 ++-- .../test/src/call/call_ring_test.dart | 84 +++++++++++-------- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/dogfooding/lib/screens/call_participants_list.dart b/dogfooding/lib/screens/call_participants_list.dart index 3e6ccc096..908e9072c 100644 --- a/dogfooding/lib/screens/call_participants_list.dart +++ b/dogfooding/lib/screens/call_participants_list.dart @@ -87,13 +87,17 @@ class CallParticipantsList extends StatelessWidget { child: Row( children: [ Expanded( - child: StreamButton.tertiary( - icon: const Icon( - Icons.link, - color: Colors.white, + // Builder provides the button's own context so the + // share sheet can be anchored to the button. + child: Builder( + builder: (buttonContext) => StreamButton.tertiary( + icon: const Icon( + Icons.link, + color: Colors.white, + ), + label: 'Share link', + onPressed: () => _shareLink(buttonContext), ), - label: 'Share link', - onPressed: () => _shareLink(context), ), ), const SizedBox(width: 12), @@ -271,7 +275,7 @@ class _ParticipantTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - participant.name, + participant.name.ifEmpty(() => participant.userId), overflow: TextOverflow.ellipsis, ), Text( diff --git a/packages/stream_video/test/src/call/call_ring_test.dart b/packages/stream_video/test/src/call/call_ring_test.dart index 17a63ea55..3d45803c3 100644 --- a/packages/stream_video/test/src/call/call_ring_test.dart +++ b/packages/stream_video/test/src/call/call_ring_test.dart @@ -23,11 +23,13 @@ void main() { /// Sets up an ongoing (connected) call started by the current user, with the /// current user already a member of the call. - Future<({ - Call call, - MutableSharedEmitterImpl events, - MockCoordinatorClient coordinatorClient, - })> + Future< + ({ + Call call, + MutableSharedEmitterImpl events, + MockCoordinatorClient coordinatorClient, + }) + > setupOngoingCall({ Map? members, }) async { @@ -121,24 +123,27 @@ void main() { ).called(1); }); - test('rings all members not in the call when no ids are provided', () async { - final setup = await setupOngoingCall(); - stubRingCall( - setup.coordinatorClient, - result: const Result.success(['user1', 'user2']), - ); + test( + 'rings all members not in the call when no ids are provided', + () async { + final setup = await setupOngoingCall(); + stubRingCall( + setup.coordinatorClient, + result: const Result.success(['user1', 'user2']), + ); - final result = await setup.call.ring(); + final result = await setup.call.ring(); - expect(result.isSuccess, isTrue); - verify( - () => setup.coordinatorClient.ringCall( - callCid: setup.call.callCid, - membersIds: const [], - video: false, - ), - ).called(1); - }); + expect(result.isSuccess, isTrue); + verify( + () => setup.coordinatorClient.ringCall( + callCid: setup.call.callCid, + membersIds: const [], + video: false, + ), + ).called(1); + }, + ); test('forwards the video flag when ringing', () async { final setup = await setupOngoingCall(); @@ -155,18 +160,21 @@ void main() { ).called(1); }); - test('propagates failure from the coordinator when ringing fails', () async { - final setup = await setupOngoingCall(); - stubRingCall( - setup.coordinatorClient, - result: const Result.failure(VideoError(message: 'ring failed')), - ); + test( + 'propagates failure from the coordinator when ringing fails', + () async { + final setup = await setupOngoingCall(); + stubRingCall( + setup.coordinatorClient, + result: const Result.failure(VideoError(message: 'ring failed')), + ); - final result = await setup.call.ring(userIds: ['user1']); + final result = await setup.call.ring(userIds: ['user1']); - expect(result.isFailure, isTrue); - expect((result as Failure).error.message, equals('ring failed')); - }); + expect(result.isFailure, isTrue); + expect((result as Failure).error.message, equals('ring failed')); + }, + ); test('adds a member to the ongoing call via the coordinator', () async { final setup = await setupOngoingCall(); @@ -185,8 +193,8 @@ void main() { ), ).captured; - final members = - (captured.single as Iterable).toList(); + final members = (captured.single as Iterable) + .toList(); expect(members, hasLength(1)); expect(members.first.userId, equals('user2')); expect(members.first.role, equals('user')); @@ -234,12 +242,14 @@ void main() { // The newly added member is now part of the call members and is not yet // a participant (not in the call). - final addedMember = setup.call.state.value.callMembers - .firstWhereOrNull((m) => m.userId == newUser.id); + final addedMember = setup.call.state.value.callMembers.firstWhereOrNull( + (m) => m.userId == newUser.id, + ); expect(addedMember, isNotNull); expect( - setup.call.state.value.callParticipants - .any((p) => p.userId == newUser.id), + setup.call.state.value.callParticipants.any( + (p) => p.userId == newUser.id, + ), isFalse, ); From 010eda36feaf88118413f22dda83ff4deebd72dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Fri, 5 Jun 2026 13:29:29 +0200 Subject: [PATCH 7/8] reduce pana scores because of dep overrides --- .github/workflows/pana.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pana.yml b/.github/workflows/pana.yml index dd2bd9c75..d13ffe449 100644 --- a/.github/workflows/pana.yml +++ b/.github/workflows/pana.yml @@ -33,7 +33,7 @@ jobs: uses: ./.github/actions/pana with: working_directory: packages/stream_video_flutter - min_score: 150 + min_score: 130 stream_video_push_notification: runs-on: ubuntu-latest @@ -44,7 +44,7 @@ jobs: uses: ./.github/actions/pana with: working_directory: packages/stream_video_push_notification - min_score: 140 + min_score: 130 stream_video_screen_sharing: runs-on: ubuntu-latest From 79666448571bee370274606ac3d500a0b9040c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Wed, 24 Jun 2026 12:11:58 +0200 Subject: [PATCH 8/8] tweak --- .../state/mixins/state_coordinator_mixin.dart | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart index b5e5fec4d..2956256bd 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_coordinator_mixin.dart @@ -93,52 +93,54 @@ mixin StateCoordinatorMixin on StateNotifier { } }).toList(); + if (!state.isRingingFlow) { + state = state.copyWith(callMembers: members); + return; + } + // Auto-disconnect on rejection only applies to the ringing flow (call - // created with `ringing: true`). Ringing users mid-call via `Call.ring()` - // must not tear down the already established call when everyone declines. - if (state.isRingingFlow) { - if (state.createdByMe) { - final everyoneElseRejected = - state.otherParticipants.isEmpty && - state.callMembers - .where((m) => m.userId != state.currentUserId) - .every((m) => rejectedBy.keys.contains(m.userId)); - - if (everyoneElseRejected) { - _logger.d( - () => '[coordinatorCallRejected] everyone rejected, disconnecting', - ); - state = state.copyWith( - status: CallStatus.disconnected( - DisconnectReason.rejected( - byUserId: event.rejectedByUserId, - reason: CallRejectReason.allOtherParticipantsRejected(), - ), + // created with `ringing: true`). + if (state.createdByMe) { + final everyoneElseRejected = + state.otherParticipants.isEmpty && + state.callMembers + .where((m) => m.userId != state.currentUserId) + .every((m) => rejectedBy.keys.contains(m.userId)); + + if (everyoneElseRejected) { + _logger.d( + () => '[coordinatorCallRejected] everyone rejected, disconnecting', + ); + state = state.copyWith( + status: CallStatus.disconnected( + DisconnectReason.rejected( + byUserId: event.rejectedByUserId, + reason: CallRejectReason.allOtherParticipantsRejected(), ), - sessionId: '', - callParticipants: const [], - callMembers: members, - ); - return; - } - } else { - if (rejectedBy.keys.contains(state.createdByUserId)) { - _logger.d( - () => '[coordinatorCallRejected] creator rejected, disconnecting', - ); - state = state.copyWith( - status: CallStatus.disconnected( - DisconnectReason.rejected( - byUserId: event.rejectedByUserId, - reason: CallRejectReason.creatorRejected(), - ), + ), + sessionId: '', + callParticipants: const [], + callMembers: members, + ); + return; + } + } else { + if (rejectedBy.keys.contains(state.createdByUserId)) { + _logger.d( + () => '[coordinatorCallRejected] creator rejected, disconnecting', + ); + state = state.copyWith( + status: CallStatus.disconnected( + DisconnectReason.rejected( + byUserId: event.rejectedByUserId, + reason: CallRejectReason.creatorRejected(), ), - sessionId: '', - callParticipants: const [], - callMembers: members, - ); - return; - } + ), + sessionId: '', + callParticipants: const [], + callMembers: members, + ); + return; } }