Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/stream_video/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Upcoming

### 🐞 Fixed
* Improved disconnect/reject reason propagation.

## 1.3.2

### 🐞 Fixed
Expand Down
76 changes: 72 additions & 4 deletions packages/stream_video/lib/src/call/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 _:
Expand Down Expand Up @@ -645,6 +647,48 @@ class Call {
}
}

Future<void> _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<void> _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);
}
Comment thread
Brazol marked this conversation as resolved.

void _handleModerationWarningEvent(
StreamCallModerationWarningEvent event,
) {
Expand Down Expand Up @@ -779,7 +823,7 @@ class Call {
/// Rejects the incoming call.
Future<Result<None>> 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,
Expand Down Expand Up @@ -2005,7 +2049,7 @@ class Call {
}

try {
_session?.leave(reason: 'user is leaving the call');
_session?.leave(reason: _sfuLeaveReason(reason));
} finally {
await _clear('leave');
}
Expand All @@ -2020,6 +2064,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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

Future<void> _clear(String src) async {
_logger.d(() => '[clear] src: $src');

Expand Down
32 changes: 31 additions & 1 deletion packages/stream_video/lib/src/call/call_reject_reason.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,47 @@ 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);

factory CallRejectReason.decline() => const CallRejectReason._('decline');
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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ mixin StateCoordinatorMixin on StateNotifier<CallState> {
status: CallStatus.disconnected(
DisconnectReason.rejected(
byUserId: event.rejectedByUserId,
reason: CallRejectReason.custom('ring: everyone rejected'),
reason: CallRejectReason.allOtherParticipantsRejected(),
),
),
sessionId: '',
Expand All @@ -126,7 +126,7 @@ mixin StateCoordinatorMixin on StateNotifier<CallState> {
status: CallStatus.disconnected(
DisconnectReason.rejected(
byUserId: event.rejectedByUserId,
reason: CallRejectReason.custom('ring: creator rejected'),
reason: CallRejectReason.creatorRejected(),
),
),
sessionId: '',
Expand Down
22 changes: 22 additions & 0 deletions packages/stream_video/lib/src/models/disconnect_reason.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions packages/stream_video/lib/src/stream_video.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}');
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions packages/stream_video_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Upcoming

### 🐞 Fixed
* Improved disconnect/reject reason propagation.

## 1.3.2

### 🐞 Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager {
_logger.d(
() => '[subscribeToEvents] Call ended event: ${event.callCid}',
);
RingingEventBroadcaster().suppressEvent();
endCallByCid(event.callCid.toString());
}),
);
Expand All @@ -88,6 +89,7 @@ class StreamVideoPushNotificationManager implements PushNotificationManager {
'[subscribeToEvents] No participants left, ending call: ${event.callCid}',
);

RingingEventBroadcaster().suppressEvent();
await endCallByCid(event.callCid.toString());
}
}),
Expand All @@ -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());
}
}),
Expand Down
Loading