From a8d06b9a314f83c6a8f9a4fb113af38c1470438e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 21 Apr 2026 10:37:17 +0200 Subject: [PATCH 1/3] improve reject and leave reasons --- packages/stream_video/CHANGELOG.md | 5 ++ packages/stream_video/lib/src/call/call.dart | 74 ++++++++++++++++++- .../lib/src/call/call_reject_reason.dart | 32 +++++++- .../lib/src/call/session/call_session.dart | 2 +- .../state/mixins/state_coordinator_mixin.dart | 4 +- .../lib/src/models/disconnect_reason.dart | 22 ++++++ .../stream_video/lib/src/stream_video.dart | 7 +- packages/stream_video_flutter/CHANGELOG.md | 5 ++ .../src/stream_video_push_notification.dart | 3 + 9 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index cecf91bf7..619369124 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,8 @@ +## Upcoming + +### 🐞 Fixed +* Improved disconnect/reject reason propagation. + ## 1.3.2 ### 🐞 Fixed diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index f986dd99f..376233b7a 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -536,9 +536,11 @@ class Call { // Notify the client about the permission request. return onPermissionRequest?.call(event); case StreamCallRejectedEvent _: - return _stateManager.coordinatorCallRejected(event); + await _handleCoordinatorCallRejected(event); + return; case StreamCallAcceptedEvent _: - return _stateManager.coordinatorCallAccepted(event); + await _handleCoordinatorCallAccepted(event); + return; case StreamCallEndedEvent _: return _stateManager.coordinatorCallEnded(event); case StreamCallPermissionsUpdatedEvent _: @@ -645,6 +647,46 @@ class Call { } } + Future _handleCoordinatorCallAccepted( + StreamCallAcceptedEvent event, + ) async { + final currentUserId = _stateManager.callState.currentUserId; + final status = state.value.status; + + if (event.acceptedByUserId == currentUserId && + status is CallStatusIncoming && + !status.acceptedByMe) { + _logger.i( + () => '[onCoordinatorEvent] call accepted on another device, ' + 'rejecting locally with userRespondedElsewhere', + ); + await reject(reason: CallRejectReason.userRespondedElsewhere()); + return; + } + + _stateManager.coordinatorCallAccepted(event); + } + + Future _handleCoordinatorCallRejected( + StreamCallRejectedEvent event, + ) async { + final currentUserId = _stateManager.callState.currentUserId; + final status = state.value.status; + + if (event.rejectedByUserId == currentUserId && + status is CallStatusIncoming && + !status.acceptedByMe) { + _logger.i( + () => '[onCoordinatorEvent] call rejected on another device, ' + 'rejecting locally with userRespondedElsewhere', + ); + await reject(reason: CallRejectReason.userRespondedElsewhere()); + return; + } + + _stateManager.coordinatorCallRejected(event); + } + void _handleModerationWarningEvent( StreamCallModerationWarningEvent event, ) { @@ -779,7 +821,7 @@ class Call { /// Rejects the incoming call. Future> reject({CallRejectReason? reason}) async { final state = this.state.value; - _logger.i(() => '[reject] state: $state'); + _logger.i(() => '[reject] reason: $reason'); final result = await _coordinatorClient.rejectCall( cid: state.callCid, @@ -2005,7 +2047,7 @@ class Call { } try { - _session?.leave(reason: 'user is leaving the call'); + _session?.leave(reason: _sfuLeaveReason(reason)); } finally { await _clear('leave'); } @@ -2020,6 +2062,30 @@ class Call { } } + String _sfuLeaveReason(DisconnectReason? reason) { + if (reason == null) return 'user is leaving the call'; + if (reason is DisconnectReasonRejected) { + return 'rejected: ${reason.reason?.value ?? 'unspecified'}'; + } else if (reason is DisconnectReasonReplaced) { + return 'replaced by another call'; + } else if (reason is DisconnectReasonReconnectionFailed) { + return 'reconnection failed'; + } else if (reason is DisconnectReasonLastParticipantLeft) { + return 'last participant left'; + } else if (reason is DisconnectReasonFailure) { + return 'failure: ${reason.error}'; + } else if (reason is DisconnectReasonSfuError) { + return 'sfu error: ${reason.error}'; + } else if (reason is DisconnectReasonCallEnded) { + return 'call ended externally'; + } else if (reason is DisconnectReasonEnded) { + return 'call ended'; + } else if (reason is DisconnectReasonTimeout) { + return 'timeout'; + } + return 'user is leaving the call'; + } + Future _clear(String src) async { _logger.d(() => '[clear] src: $src'); diff --git a/packages/stream_video/lib/src/call/call_reject_reason.dart b/packages/stream_video/lib/src/call/call_reject_reason.dart index d87affc0f..4cabe37ee 100644 --- a/packages/stream_video/lib/src/call/call_reject_reason.dart +++ b/packages/stream_video/lib/src/call/call_reject_reason.dart @@ -2,10 +2,14 @@ import 'package:equatable/equatable.dart'; /// Reason for rejecting a call. /// -/// busy - when the callee is busy and cannot accept the call /// decline - when the callee intentionally declines the call /// cancel - when the caller cancels the call +/// busy - when the callee is busy and cannot accept the call /// timeout - when the call times out +/// callEnded - when the call was ended externally (backend event, creator +/// cancelled, or all other participants rejected) before the user answered +/// callEndedLocally - when the local SDK already ended the call and the +/// ringing flow is being brought back in sync class CallRejectReason with EquatableMixin { const CallRejectReason._(this.value); @@ -13,6 +17,32 @@ class CallRejectReason with EquatableMixin { factory CallRejectReason.cancel() => const CallRejectReason._('cancel'); factory CallRejectReason.busy() => const CallRejectReason._('busy'); factory CallRejectReason.timeout() => const CallRejectReason._('timeout'); + + /// The call was ended externally before the user could answer — e.g. a + /// backend `call.ended` event, the creator cancelled, or every other + /// participant already rejected. + factory CallRejectReason.callEnded() => + const CallRejectReason._('call-ended'); + + /// The local SDK already ended the call and the ringing UI / CallKit is + /// being brought back in sync. + factory CallRejectReason.callEndedLocally() => + const CallRejectReason._('call-ended-locally'); + + /// The same user already accepted, rejected, or missed the ringing call on + /// another device. + factory CallRejectReason.userRespondedElsewhere() => + const CallRejectReason._('user-responded-elsewhere'); + + /// The caller cancelled the ringing flow before any other invitee accepted. + factory CallRejectReason.creatorRejected() => + const CallRejectReason._('ring: creator rejected'); + + /// Every invitee other than the current user and the caller has already + /// rejected the ringing call. + factory CallRejectReason.allOtherParticipantsRejected() => + const CallRejectReason._('ring: everyone rejected'); + factory CallRejectReason.custom(String customType) => CallRejectReason._(customType); diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index c2b1fe757..a7c23e1d2 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -510,7 +510,7 @@ class CallSession extends Disposable { } void leave({String? reason}) { - _logger.d(() => '[leave] no args'); + _logger.d(() => '[leave] reason: $reason'); _isLeavingOrClosed = true; sfuWS.leave(sessionId: sessionId, reason: reason); } 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 a32c8f640..bb44ce94e 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 @@ -108,7 +108,7 @@ mixin StateCoordinatorMixin on StateNotifier { status: CallStatus.disconnected( DisconnectReason.rejected( byUserId: event.rejectedByUserId, - reason: CallRejectReason.custom('ring: everyone rejected'), + reason: CallRejectReason.allOtherParticipantsRejected(), ), ), sessionId: '', @@ -126,7 +126,7 @@ mixin StateCoordinatorMixin on StateNotifier { status: CallStatus.disconnected( DisconnectReason.rejected( byUserId: event.rejectedByUserId, - reason: CallRejectReason.custom('ring: creator rejected'), + reason: CallRejectReason.creatorRejected(), ), ), sessionId: '', diff --git a/packages/stream_video/lib/src/models/disconnect_reason.dart b/packages/stream_video/lib/src/models/disconnect_reason.dart index d9781e882..9c25a2a9a 100644 --- a/packages/stream_video/lib/src/models/disconnect_reason.dart +++ b/packages/stream_video/lib/src/models/disconnect_reason.dart @@ -34,6 +34,12 @@ abstract class DisconnectReason extends Equatable { return DisconnectReasonEnded(); } + /// The call was ended externally (e.g. a backend `call.ended` event or + /// CallKit reporting the call ended) while the user was still connected. + factory DisconnectReason.callEnded() { + return DisconnectReasonCallEnded(); + } + factory DisconnectReason.replaced() { return DisconnectReasonReplaced(); } @@ -194,6 +200,22 @@ class DisconnectReasonReconnectionFailed extends DisconnectReason { } } +class DisconnectReasonCallEnded extends DisconnectReason { + factory DisconnectReasonCallEnded() { + return _instance; + } + + const DisconnectReasonCallEnded._internal(); + + static const DisconnectReasonCallEnded _instance = + DisconnectReasonCallEnded._internal(); + + @override + String toString() { + return 'CallEnded'; + } +} + class DisconnectReasonManuallyClosed extends DisconnectReason { factory DisconnectReasonManuallyClosed() { return _instance; diff --git a/packages/stream_video/lib/src/stream_video.dart b/packages/stream_video/lib/src/stream_video.dart index dc66decd6..2bda4551c 100644 --- a/packages/stream_video/lib/src/stream_video.dart +++ b/packages/stream_video/lib/src/stream_video.dart @@ -46,6 +46,7 @@ import 'models/call_preferences.dart'; import 'models/call_received_data.dart'; import 'models/call_ringing_data.dart'; import 'models/call_status.dart'; +import 'models/disconnect_reason.dart'; import 'models/guest_created_data.dart'; import 'models/push_device.dart'; import 'models/push_provider.dart'; @@ -997,7 +998,9 @@ class StreamVideo extends Disposable { final incomingCall = _state.incomingCall.valueOrNull; if (activeCall?.callCid.value == cid) { - final result = await activeCall?.leave(); + final result = await activeCall?.leave( + reason: DisconnectReason.callEnded(), + ); if (result is Failure) { _logger.d(() => '[onCallEnded] error leaving call: ${result.error}'); @@ -1006,7 +1009,7 @@ class StreamVideo extends Disposable { final status = incomingCall?.state.value.status; if (status is CallStatusIncoming && !status.acceptedByMe) { final result = await incomingCall?.reject( - reason: CallRejectReason.decline(), + reason: CallRejectReason.callEnded(), ); if (result is Failure) { _logger.d( diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index c6459f95d..b46dc3846 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## Upcoming + +### 🐞 Fixed +* Improved disconnect/reject reason propagation. + ## 1.3.2 ### 🐞 Fixed diff --git a/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart b/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart index 36855c5e9..ee383a0b1 100644 --- a/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart +++ b/packages/stream_video_push_notification/lib/src/stream_video_push_notification.dart @@ -63,6 +63,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { _logger.d( () => '[subscribeToEvents] Call ended event: ${event.callCid}', ); + RingingEventBroadcaster().suppressEvent(); endCallByCid(event.callCid.toString()); }), ); @@ -88,6 +89,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { '[subscribeToEvents] No participants left, ending call: ${event.callCid}', ); + RingingEventBroadcaster().suppressEvent(); await endCallByCid(event.callCid.toString()); } }), @@ -107,6 +109,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager { '[subscribeToEvents] Call rejected by the current user or call owner, ending call: ${event.callCid}', ); + RingingEventBroadcaster().suppressEvent(); await endCallByCid(event.callCid.toString()); } }), From 82b0d2b56e91050a1a39ba539b8ea573648b09fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 28 Apr 2026 07:46:23 +0200 Subject: [PATCH 2/3] format --- packages/stream_video/lib/src/call/call.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 376233b7a..0ec8bd093 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -657,7 +657,8 @@ class Call { status is CallStatusIncoming && !status.acceptedByMe) { _logger.i( - () => '[onCoordinatorEvent] call accepted on another device, ' + () => + '[onCoordinatorEvent] call accepted on another device, ' 'rejecting locally with userRespondedElsewhere', ); await reject(reason: CallRejectReason.userRespondedElsewhere()); @@ -677,7 +678,8 @@ class Call { status is CallStatusIncoming && !status.acceptedByMe) { _logger.i( - () => '[onCoordinatorEvent] call rejected on another device, ' + () => + '[onCoordinatorEvent] call rejected on another device, ' 'rejecting locally with userRespondedElsewhere', ); await reject(reason: CallRejectReason.userRespondedElsewhere()); From 136739e15ffc0ef9de8268c3832acdb1392a45cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 28 Apr 2026 10:34:18 +0200 Subject: [PATCH 3/3] tweak --- packages/stream_video/lib/src/call/call.dart | 38 ++++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index abb534b13..a2edb0cd4 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -2080,26 +2080,24 @@ class Call { String _sfuLeaveReason(DisconnectReason? reason) { if (reason == null) return 'user is leaving the call'; - if (reason is DisconnectReasonRejected) { - return 'rejected: ${reason.reason?.value ?? 'unspecified'}'; - } else if (reason is DisconnectReasonReplaced) { - return 'replaced by another call'; - } else if (reason is DisconnectReasonReconnectionFailed) { - return 'reconnection failed'; - } else if (reason is DisconnectReasonLastParticipantLeft) { - return 'last participant left'; - } else if (reason is DisconnectReasonFailure) { - return 'failure: ${reason.error}'; - } else if (reason is DisconnectReasonSfuError) { - return 'sfu error: ${reason.error}'; - } else if (reason is DisconnectReasonCallEnded) { - return 'call ended externally'; - } else if (reason is DisconnectReasonEnded) { - return 'call ended'; - } else if (reason is DisconnectReasonTimeout) { - return 'timeout'; - } - return 'user is leaving the call'; + + return switch (reason) { + final DisconnectReasonRejected rejected => + 'rejected: ${rejected.reason?.value ?? 'unspecified'}', + final DisconnectReasonFailure failure => 'failure: ${failure.error}', + final DisconnectReasonSfuError sfuError => 'sfu error: ${sfuError.error}', + final DisconnectReasonCancelled cancelled => + 'cancelled: ${cancelled.byUserId}', + DisconnectReasonReplaced _ => 'replaced by another call', + DisconnectReasonReconnectionFailed _ => 'reconnection failed', + DisconnectReasonLastParticipantLeft _ => 'last participant left', + DisconnectReasonCallEnded _ => 'call ended externally', + DisconnectReasonEnded _ => 'call ended', + DisconnectReasonTimeout _ => 'timeout', + DisconnectReasonManuallyClosed _ => 'manually closed', + DisconnectReasonBlocked _ => 'blocked', + _ => 'user is leaving the call', + }; } Future _clear(String src) async {