diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 8945ae747..39327120d 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,6 +1,7 @@ ## Upcoming ### 🐞 Fixed +* Improved disconnect/reject reason propagation. * Fixed `TranscriptionSettingsResponse.fromJson` crashing with a null check error when the backend returns an empty string for the `language` field. ### ✅ Added diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index e190a0024..a2edb0cd4 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,48 @@ 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, ) { @@ -780,7 +824,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'); _session?.trace('call.reject', reason?.value); final result = await _coordinatorClient.rejectCall( @@ -2019,7 +2063,7 @@ class Call { } try { - _session?.leave(reason: 'user is leaving the call'); + _session?.leave(reason: _sfuLeaveReason(reason)); } finally { await _clear('leave'); } @@ -2034,6 +2078,28 @@ class Call { } } + String _sfuLeaveReason(DisconnectReason? reason) { + if (reason == null) 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 { _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 bfceafa1a..7ba92a199 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; _tracer.trace('call.leave', reason); 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()); } }),