Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ coverage_badge.svg
coverage.xml
TEST-report.*
rust
coverage_dir/*

# IntelliJ related
*.iml
Expand Down
81 changes: 53 additions & 28 deletions lib/src/voip/call_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -465,9 +465,14 @@ class CallSession {
await pc!.setLocalDescription(answer);
}
} catch (e, s) {
Logs().e('[VOIP] onNegotiateReceived => ', e, s);
await _getLocalOfferFailed(e);
return;
Logs().e('[VOIP] onNegotiateReceived => Failed to get local offer', e, s);
fireCallEvent(CallStateChange.kError);
await terminate(CallParty.kLocal, CallErrorCode.localOfferFailed, true);
throw CallError(
CallErrorCode.localOfferFailed,
'Failed to get local offer',
e,
);
}

final newLocalOnHold = await isLocalOnHold();
Expand Down Expand Up @@ -592,7 +597,6 @@ class CallSession {
await addLocalStream(stream, SDPStreamMetadataPurpose.Screenshare);
return true;
} catch (err) {
fireCallEvent(CallStateChange.kError);
return false;
}
} else {
Expand Down Expand Up @@ -1147,9 +1151,15 @@ class CallSession {
await Future.delayed(voip.timeouts!.delayBeforeOffer);
final offer = await pc!.createOffer({});
await _gotLocalOffer(offer);
} catch (e) {
await _getLocalOfferFailed(e);
return;
} catch (e, s) {
Logs().e('[VOIP] onNegotiationNeeded => Failed to get local offer', e, s);
fireCallEvent(CallStateChange.kError);
await terminate(CallParty.kLocal, CallErrorCode.localOfferFailed, true);
throw CallError(
CallErrorCode.localOfferFailed,
'Failed to get local offer',
e,
);
} finally {
_makingOffer = false;
}
Expand Down Expand Up @@ -1217,8 +1227,23 @@ class CallSession {
}
}
};
} catch (e) {
Logs().v('[VOIP] prepareMediaStream error => ${e.toString()}');
} catch (e, s) {
Logs().e(
'[VOIP] preparePeerConnection => Failed to create peer connection object',
e,
s,
);
fireCallEvent(CallStateChange.kError);
await terminate(
CallParty.kLocal,
CallErrorCode.createPeerConnectionFailed,
true,
);
throw CallError(
CallErrorCode.createPeerConnectionFailed,
'Failed to create peer connection object ',
e,
);
}
}

Expand Down Expand Up @@ -1311,20 +1336,33 @@ class CallSession {
};
try {
return await voip.delegate.mediaDevices.getUserMedia(mediaConstraints);
} catch (e) {
await _getUserMediaFailed(e);
rethrow;
} catch (e, s) {
Logs().w('Failed to get user media - ending call', e, s);
fireCallEvent(CallStateChange.kError);
await terminate(CallParty.kLocal, CallErrorCode.userMediaFailed, true);
throw CallError(
CallErrorCode.userMediaFailed,
'Failed to get user media',
e,
);
}
}

Future<MediaStream?> _getDisplayMedia() async {
try {
return await voip.delegate.mediaDevices
.getDisplayMedia(UserMediaConstraints.screenMediaConstraints);
} catch (e) {
await _getUserMediaFailed(e);
} catch (e, s) {
Logs().w('Failed to get display media', e, s);
fireCallEvent(CallStateChange.kError);
// We don't terminate the call here because the user might still want to stay
// on the call and try again later.
throw CallError(
CallErrorCode.displayMediaFailed,
'Failed to get display media',
e,
);
}
return null;
}

Future<RTCPeerConnection> _createPeerConnection() async {
Expand Down Expand Up @@ -1454,19 +1492,6 @@ class CallSession {
}
}

Future<void> _getLocalOfferFailed(dynamic err) async {
Logs().e('Failed to get local offer ${err.toString()}');
fireCallEvent(CallStateChange.kError);

await terminate(CallParty.kLocal, CallErrorCode.localOfferFailed, true);
}

Future<void> _getUserMediaFailed(dynamic err) async {
Logs().w('Failed to get user media - ending call ${err.toString()}');
fireCallEvent(CallStateChange.kError);
await terminate(CallParty.kLocal, CallErrorCode.userMediaFailed, true);
}

Future<void> onSelectAnswerReceived(String? selectedPartyId) async {
if (direction != CallDirection.kIncoming) {
Logs().w('Got select_answer for an outbound call: ignoring');
Expand Down
8 changes: 8 additions & 0 deletions lib/src/voip/utils/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,21 @@ enum CallErrorCode {
/// The user chose to end the call
userHangup('user_hangup'),

/// An error code when creating peer connection object fails locally.
createPeerConnectionFailed('create_peer_connection_failed'),

/// An error code when the local client failed to create an offer.
localOfferFailed('local_offer_failed'),

/// An error code when there is no local mic/camera to use. This may be because
/// the hardware isn't plugged in, or the user has explicitly denied access.
userMediaFailed('user_media_failed'),

/// An error code when there is no local display to screenshare. This may be
/// because the hardware isn't plugged in, or the user has explicitly denied
/// access.
displayMediaFailed('display_media_failed'),

/// Error code used when a call event failed to send
/// because unknown devices were present in the room
unknownDevice('unknown_device'),
Expand Down
46 changes: 46 additions & 0 deletions test/calls_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,52 @@ void main() {
);
});

test('Call fails when peer connection creation fails', () async {
final mockDelegate = MockWebRTCDelegate()
..throwOnCreatePeerConnection = true;
voip = VoIP(matrix, mockDelegate);
VoIP.customTxid = '1234';

try {
await voip.inviteToCall(
room,
CallType.kVoice,
userId: '@alice:testing.com',
);
fail('Expected call to fail');
} catch (e) {
expect(e, isA<CallError>());
expect((e as CallError).code, CallErrorCode.createPeerConnectionFailed);
expect(voip.currentCID, null);
}
});

test('Call continues when getDisplayMedia fails', () async {
final mockDelegate = MockWebRTCDelegate();
mockDelegate.mediaDevices.throwOnGetDisplayMedia = true;
voip = VoIP(matrix, mockDelegate);
VoIP.customTxid = '1234';

final call = await voip.inviteToCall(
room,
CallType.kVoice,
userId: '@alice:testing.com',
);

// Attempt to share screen - should not throw or terminate call
try {
await call.setScreensharingEnabled(true);
} catch (e) {
fail('Screen sharing failure should be handled internally');
}

expect(call.onCallEventChanged.value, CallStateChange.kError);
expect(call.state, isNot(CallState.kEnded));
expect(voip.currentCID, isNotNull);

await call.hangup(reason: CallErrorCode.userHangup);
});

test('getFamedlyCallEvents sort order', () {
room.setState(
Event(
Expand Down
19 changes: 16 additions & 3 deletions test/webrtc_stub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:matrix/matrix.dart';

class MockWebRTCDelegate implements WebRTCDelegate {
bool throwOnCreatePeerConnection = false;

@override
bool get canHandleNewCall => true;

@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) async =>
MockRTCPeerConnection();
]) async {
if (throwOnCreatePeerConnection) {
throw Exception('mock exception while creating peer connection');
}
return MockRTCPeerConnection();
}

@override
Future<void> registerListeners(CallSession session) async {
Expand Down Expand Up @@ -48,8 +54,10 @@ class MockWebRTCDelegate implements WebRTCDelegate {
@override
bool get isWeb => false;

final _mockMediaDevices = MockMediaDevices();

@override
MediaDevices get mediaDevices => MockMediaDevices();
MockMediaDevices get mediaDevices => _mockMediaDevices;

@override
Future<void> playRingtone() async {
Expand Down Expand Up @@ -105,6 +113,8 @@ class MockMediaDeviceInfo implements MediaDeviceInfo {
}

class MockMediaDevices implements MediaDevices {
bool throwOnGetDisplayMedia = false;

@override
Function(dynamic event)? ondevicechange;

Expand All @@ -128,6 +138,9 @@ class MockMediaDevices implements MediaDevices {
Future<MediaStream> getDisplayMedia(
Map<String, dynamic> mediaConstraints,
) async {
if (throwOnGetDisplayMedia) {
throw Exception('mock exception while getting display media');
}
return MockMediaStream('', '');
}

Expand Down