From 5b1169cc28faa5926588cad952ef2cae4a505893 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 11 Sep 2025 09:38:54 +0000 Subject: [PATCH 01/11] WEBRTC-2963: Implement Ice Trickle Support for Flutter SDK - Add useTrickleIce parameter to Config base class and implementations - Add new socket methods for telnyx_rtc.candidate and telnyx_rtc.endOfCandidates - Create CandidateMessage and EndOfCandidatesMessage classes for JSON-RPC communication - Add getUseTrickleIce() method to TelnyxClient - Update Peer constructor to accept useTrickleIce parameter and implement trickle ICE logic - Modify TelnyxClient newInvite and acceptCall methods to pass useTrickleIce - Implement _sendTrickleCandidate and _sendEndOfCandidates methods in Peer class - Update Profile model to include useTrickleIce field with toJson and copyWith support - Add trickle ICE toggle to demo app overflow menu - Update ProfileProvider with toggleTrickleIce method - Update ConfigHelper to persist useTrickleIce setting in SharedPreferences - Update TelnyxClientViewModel to save/clear useTrickleIce configuration --- lib/model/profile_model.dart | 6 ++ lib/provider/profile_provider.dart | 22 ++++++ lib/utils/config_helper.dart | 4 ++ lib/view/screen/home_screen.dart | 9 ++- lib/view/telnyx_client_view_model.dart | 4 ++ .../lib/config/telnyx_config.dart | 12 ++++ .../lib/model/socket_method.dart | 2 + .../verto/send/candidate_message_body.dart | 71 +++++++++++++++++++ .../send/end_of_candidates_message_body.dart | 62 ++++++++++++++++ packages/telnyx_webrtc/lib/peer/peer.dart | 66 ++++++++++++++++- packages/telnyx_webrtc/lib/telnyx_client.dart | 8 +++ 11 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 packages/telnyx_webrtc/lib/model/verto/send/candidate_message_body.dart create mode 100644 packages/telnyx_webrtc/lib/model/verto/send/end_of_candidates_message_body.dart diff --git a/lib/model/profile_model.dart b/lib/model/profile_model.dart index de606f4b..92f0955e 100644 --- a/lib/model/profile_model.dart +++ b/lib/model/profile_model.dart @@ -22,6 +22,7 @@ class Profile { final Region region; final bool fallbackOnRegionFailure; final bool forceRelayCandidate; + final bool useTrickleIce; Profile({ required this.isTokenLogin, @@ -35,6 +36,7 @@ class Profile { this.region = Region.auto, this.fallbackOnRegionFailure = true, this.forceRelayCandidate = false, + this.useTrickleIce = false, }); factory Profile.fromJson(Map json) { @@ -53,6 +55,7 @@ class Profile { ), fallbackOnRegionFailure: json['fallbackOnRegionFailure'] as bool? ?? true, forceRelayCandidate: json['forceRelayCandidate'] as bool? ?? false, + useTrickleIce: json['useTrickleIce'] as bool? ?? false, ); } @@ -69,6 +72,7 @@ class Profile { 'region': region.value, 'fallbackOnRegionFailure': fallbackOnRegionFailure, 'forceRelayCandidate': forceRelayCandidate, + 'useTrickleIce': useTrickleIce, }; } @@ -84,6 +88,7 @@ class Profile { Region? region, bool? fallbackOnRegionFailure, bool? forceRelayCandidate, + bool? useTrickleIce, }) { return Profile( isTokenLogin: isTokenLogin ?? this.isTokenLogin, @@ -98,6 +103,7 @@ class Profile { fallbackOnRegionFailure: fallbackOnRegionFailure ?? this.fallbackOnRegionFailure, forceRelayCandidate: forceRelayCandidate ?? this.forceRelayCandidate, + useTrickleIce: useTrickleIce ?? this.useTrickleIce, ); } diff --git a/lib/provider/profile_provider.dart b/lib/provider/profile_provider.dart index 6ea8df7f..0ef1a604 100644 --- a/lib/provider/profile_provider.dart +++ b/lib/provider/profile_provider.dart @@ -124,4 +124,26 @@ class ProfileProvider with ChangeNotifier { notifyListeners(); } } + + Future toggleTrickleIce() async { + if (_selectedProfile != null) { + final updatedProfile = _selectedProfile!.copyWith( + useTrickleIce: !_selectedProfile!.useTrickleIce, + ); + + // Update the profile in the list + final index = _profiles.indexWhere( + (p) => p.sipCallerIDName == _selectedProfile!.sipCallerIDName, + ); + if (index != -1) { + _profiles[index] = updatedProfile; + } + + // Update the selected profile + _selectedProfile = updatedProfile; + + await _saveProfiles(); + notifyListeners(); + } + } } diff --git a/lib/utils/config_helper.dart b/lib/utils/config_helper.dart index ba8b5895..7c584cde 100644 --- a/lib/utils/config_helper.dart +++ b/lib/utils/config_helper.dart @@ -17,6 +17,7 @@ class ConfigHelper { final sipNumber = prefs.getString('sipNumber'); final notificationToken = prefs.getString('notificationToken'); final forceRelayCandidate = prefs.getBool('forceRelayCandidate') ?? false; + final useTrickleIce = prefs.getBool('useTrickleIce') ?? false; if (sipUser != null && sipPassword != null && @@ -33,6 +34,7 @@ class ConfigHelper { debug: false, reconnectionTimeout: 30000, forceRelayCandidate: forceRelayCandidate, + useTrickleIce: useTrickleIce, ); } } catch (e) { @@ -53,6 +55,7 @@ class ConfigHelper { final sipNumber = prefs.getString('sipNumber'); final notificationToken = prefs.getString('notificationToken'); final forceRelayCandidate = prefs.getBool('forceRelayCandidate') ?? false; + final useTrickleIce = prefs.getBool('useTrickleIce') ?? false; if (token != null && sipName != null && sipNumber != null) { return TokenConfig( @@ -64,6 +67,7 @@ class ConfigHelper { customLogger: CustomSDKLogger(), debug: false, forceRelayCandidate: forceRelayCandidate, + useTrickleIce: useTrickleIce, ); } } catch (e) { diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index 8c14f26b..a5b7bb4a 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -117,6 +117,10 @@ class _HomeScreenState extends State { case 'Disable Debugging': Provider.of(context, listen: false).toggleDebugMode(); break; + case 'Enable Trickle ICE': + case 'Disable Trickle ICE': + Provider.of(context, listen: false).toggleTrickleIce(); + break; case 'Assistant Login': _showAssistantLoginDialog(); break; @@ -196,7 +200,10 @@ class _HomeScreenState extends State { final debugToggleText = selectedProfile.isDebug ? 'Disable Debugging' : 'Enable Debugging'; - return {'Export Logs', debugToggleText, 'Assistant Login'} + final trickleIceToggleText = selectedProfile.useTrickleIce + ? 'Disable Trickle ICE' + : 'Enable Trickle ICE'; + return {'Export Logs', debugToggleText, trickleIceToggleText, 'Assistant Login'} .map((String choice) { return PopupMenuItem( value: choice, diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index 560cff36..2f3c10ee 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -379,6 +379,8 @@ class TelnyxClientViewModel with ChangeNotifier { if (config.notificationToken != null) { await prefs.setString('notificationToken', config.notificationToken!); } + await prefs.setBool('forceRelayCandidate', config.forceRelayCandidate); + await prefs.setBool('useTrickleIce', config.useTrickleIce); } Future _clearConfigForAutoLogin() async { @@ -389,6 +391,8 @@ class TelnyxClientViewModel with ChangeNotifier { await prefs.remove('sipName'); await prefs.remove('sipNumber'); await prefs.remove('notificationToken'); + await prefs.remove('forceRelayCandidate'); + await prefs.remove('useTrickleIce'); } void observeResponses() { diff --git a/packages/telnyx_webrtc/lib/config/telnyx_config.dart b/packages/telnyx_webrtc/lib/config/telnyx_config.dart index 5d607ae3..93d600ae 100644 --- a/packages/telnyx_webrtc/lib/config/telnyx_config.dart +++ b/packages/telnyx_webrtc/lib/config/telnyx_config.dart @@ -19,6 +19,7 @@ class Config { this.region = Region.auto, this.fallbackOnRegionFailure = true, this.forceRelayCandidate = false, + this.useTrickleIce = false, }); /// Name associated with the SIP account @@ -65,6 +66,13 @@ class Config { /// as all media will be relayed through TURN servers. /// - Important: This setting is disabled by default to maintain optimal call quality. final bool forceRelayCandidate; + + /// Controls whether the SDK should use Trickle ICE for peer connections. + /// When enabled, ICE candidates are sent individually as they are discovered, + /// allowing for faster call establishment. When disabled, the SDK waits for + /// all ICE candidates to be gathered before sending the offer/answer. + /// - Note: This setting is disabled by default to maintain compatibility. + final bool useTrickleIce; } /// Creates an instance of CredentialConfig which can be used to log in @@ -80,6 +88,7 @@ class Config { /// [ringbackPath] is the path to the ringback file (audio to play when calling) /// [customLogger] is a custom logger to use for logging - if left null the default logger will be used which uses the Logger package /// [forceRelayCandidate] controls whether the SDK should force TURN relay for peer connections (default: false) +/// [useTrickleIce] controls whether the SDK should use Trickle ICE for peer connections (default: false) class CredentialConfig extends Config { /// Creates an instance of CredentialConfig which can be used to log in /// @@ -109,6 +118,7 @@ class CredentialConfig extends Config { super.region = Region.auto, super.fallbackOnRegionFailure = true, super.forceRelayCandidate = false, + super.useTrickleIce = false, }); /// SIP username to log in with. Either a SIP Credential from the Portal or a Generated Credential from the API @@ -131,6 +141,7 @@ class CredentialConfig extends Config { /// [ringbackPath] is the path to the ringback file (audio to play when calling) /// [customLogger] is a custom logger to use for logging - if left null the default logger will be used which uses the Logger package /// [forceRelayCandidate] controls whether the SDK should force TURN relay for peer connections (default: false) +/// [useTrickleIce] controls whether the SDK should use Trickle ICE for peer connections (default: false) class TokenConfig extends Config { /// Creates an instance of TokenConfig which can be used to log in /// @@ -159,6 +170,7 @@ class TokenConfig extends Config { super.region = Region.auto, super.fallbackOnRegionFailure = true, super.forceRelayCandidate = false, + super.useTrickleIce = false, }); /// Token to log in with. The token would be generated from a Generated Credential via the API diff --git a/packages/telnyx_webrtc/lib/model/socket_method.dart b/packages/telnyx_webrtc/lib/model/socket_method.dart index e601d6c2..13b6534d 100644 --- a/packages/telnyx_webrtc/lib/model/socket_method.dart +++ b/packages/telnyx_webrtc/lib/model/socket_method.dart @@ -15,4 +15,6 @@ class SocketMethod { static const attach = 'telnyx_rtc.attach'; static const disablePush = 'telnyx_rtc.disable_push_notification'; static const aiConversation = 'ai_conversation'; + static const candidate = 'telnyx_rtc.candidate'; + static const endOfCandidates = 'telnyx_rtc.endOfCandidates'; } diff --git a/packages/telnyx_webrtc/lib/model/verto/send/candidate_message_body.dart b/packages/telnyx_webrtc/lib/model/verto/send/candidate_message_body.dart new file mode 100644 index 00000000..4f8b3ab6 --- /dev/null +++ b/packages/telnyx_webrtc/lib/model/verto/send/candidate_message_body.dart @@ -0,0 +1,71 @@ +class CandidateMessage { + String? id; + String? jsonrpc; + String? method; + CandidateParams? params; + + CandidateMessage({this.id, this.jsonrpc, this.method, this.params}); + + CandidateMessage.fromJson(Map json) { + id = json['id'].toString(); + jsonrpc = json['jsonrpc']; + method = json['method']; + params = json['params'] != null ? CandidateParams.fromJson(json['params']) : null; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['jsonrpc'] = jsonrpc; + data['method'] = method; + if (params != null) { + data['params'] = params!.toJson(); + } + return data; + } +} + +class CandidateParams { + CandidateDialogParams? dialogParams; + String? candidate; + String? sdpMid; + int? sdpMLineIndex; + + CandidateParams({this.dialogParams, this.candidate, this.sdpMid, this.sdpMLineIndex}); + + CandidateParams.fromJson(Map json) { + dialogParams = json['dialogParams'] != null + ? CandidateDialogParams.fromJson(json['dialogParams']) + : null; + candidate = json['candidate']; + sdpMid = json['sdpMid']; + sdpMLineIndex = json['sdpMLineIndex']; + } + + Map toJson() { + final Map data = {}; + if (dialogParams != null) { + data['dialogParams'] = dialogParams!.toJson(); + } + data['candidate'] = candidate; + data['sdpMid'] = sdpMid; + data['sdpMLineIndex'] = sdpMLineIndex; + return data; + } +} + +class CandidateDialogParams { + String? callID; + + CandidateDialogParams({this.callID}); + + CandidateDialogParams.fromJson(Map json) { + callID = json['callID']; + } + + Map toJson() { + final Map data = {}; + data['callID'] = callID; + return data; + } +} \ No newline at end of file diff --git a/packages/telnyx_webrtc/lib/model/verto/send/end_of_candidates_message_body.dart b/packages/telnyx_webrtc/lib/model/verto/send/end_of_candidates_message_body.dart new file mode 100644 index 00000000..2af76bd3 --- /dev/null +++ b/packages/telnyx_webrtc/lib/model/verto/send/end_of_candidates_message_body.dart @@ -0,0 +1,62 @@ +class EndOfCandidatesMessage { + String? id; + String? jsonrpc; + String? method; + EndOfCandidatesParams? params; + + EndOfCandidatesMessage({this.id, this.jsonrpc, this.method, this.params}); + + EndOfCandidatesMessage.fromJson(Map json) { + id = json['id'].toString(); + jsonrpc = json['jsonrpc']; + method = json['method']; + params = json['params'] != null ? EndOfCandidatesParams.fromJson(json['params']) : null; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['jsonrpc'] = jsonrpc; + data['method'] = method; + if (params != null) { + data['params'] = params!.toJson(); + } + return data; + } +} + +class EndOfCandidatesParams { + EndOfCandidatesDialogParams? dialogParams; + + EndOfCandidatesParams({this.dialogParams}); + + EndOfCandidatesParams.fromJson(Map json) { + dialogParams = json['dialogParams'] != null + ? EndOfCandidatesDialogParams.fromJson(json['dialogParams']) + : null; + } + + Map toJson() { + final Map data = {}; + if (dialogParams != null) { + data['dialogParams'] = dialogParams!.toJson(); + } + return data; + } +} + +class EndOfCandidatesDialogParams { + String? callID; + + EndOfCandidatesDialogParams({this.callID}); + + EndOfCandidatesDialogParams.fromJson(Map json) { + callID = json['callID']; + } + + Map toJson() { + final Map data = {}; + data['callID'] = callID; + return data; + } +} \ No newline at end of file diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index 273c3218..b05ba445 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -6,6 +6,8 @@ import 'package:telnyx_webrtc/call.dart'; import 'package:telnyx_webrtc/config.dart'; import 'package:telnyx_webrtc/model/socket_method.dart'; import 'package:telnyx_webrtc/model/verto/send/invite_answer_message_body.dart'; +import 'package:telnyx_webrtc/model/verto/send/candidate_message_body.dart'; +import 'package:telnyx_webrtc/model/verto/send/end_of_candidates_message_body.dart'; import 'package:telnyx_webrtc/peer/session.dart'; import 'package:telnyx_webrtc/peer/signaling_state.dart'; import 'package:telnyx_webrtc/telnyx_client.dart'; @@ -26,7 +28,7 @@ class Peer { RTCPeerConnection? peerConnection; /// The constructor for the Peer class. - Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate); + Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, this._useTrickleIce); final String _selfId = randomNumeric(6); @@ -34,6 +36,7 @@ class Peer { final TelnyxClient _txClient; final bool _debug; final bool _forceRelayCandidate; + final bool _useTrickleIce; WebRTCStatsReporter? _statsManager; // Add negotiation timer fields @@ -499,8 +502,14 @@ class Peer { if (isValidCandidate) { GlobalLogger().i('Peer :: Valid ICE candidate: $candidateString'); - // Add valid candidates - await peerConnection?.addCandidate(candidate); + + if (_useTrickleIce) { + // Send candidate immediately via trickle ICE + _sendTrickleCandidate(candidate, callId); + } else { + // Add valid candidates for traditional ICE gathering + await peerConnection?.addCandidate(candidate); + } } else { GlobalLogger().i( 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', @@ -508,6 +517,10 @@ class Peer { } } else { GlobalLogger().i('Peer :: onIceCandidate: complete!'); + if (_useTrickleIce) { + // Send end of candidates signal + _sendEndOfCandidates(callId); + } } }; @@ -694,4 +707,51 @@ class Peer { _negotiationTimer?.cancel(); _negotiationTimer = null; } + + /// Sends a trickle ICE candidate to the remote peer + void _sendTrickleCandidate(RTCIceCandidate candidate, String callId) { + try { + final candidateParams = CandidateParams( + dialogParams: CandidateDialogParams(callID: callId), + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex, + ); + + final candidateMessage = CandidateMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: SocketMethod.candidate, + params: candidateParams, + ); + + final String jsonCandidateMessage = jsonEncode(candidateMessage); + GlobalLogger().i('Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + _send(jsonCandidateMessage); + } catch (e) { + GlobalLogger().e('Peer :: Error sending trickle ICE candidate: $e'); + } + } + + /// Sends end of candidates signal to the remote peer + void _sendEndOfCandidates(String callId) { + try { + final endOfCandidatesParams = EndOfCandidatesParams( + dialogParams: EndOfCandidatesDialogParams(callID: callId), + ); + + final endOfCandidatesMessage = EndOfCandidatesMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: SocketMethod.endOfCandidates, + params: endOfCandidatesParams, + ); + + final String jsonEndOfCandidatesMessage = jsonEncode(endOfCandidatesMessage); + GlobalLogger().i('Peer :: Sending end of candidates signal'); + _send(jsonEndOfCandidatesMessage); + } catch (e) { + GlobalLogger().e('Peer :: Error sending end of candidates: $e'); + } + } } diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index 06451015..4d6eef4e 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -249,6 +249,12 @@ class TelnyxClient { return config?.forceRelayCandidate ?? false; } + /// Returns the useTrickleIce setting from the current config + bool getUseTrickleIce() { + final config = _storedCredentialConfig ?? _storedTokenConfig; + return config?.useTrickleIce ?? false; + } + /// Returns whether or not the client is connected to the socket connection bool isConnected() { return _connected; @@ -1314,6 +1320,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), + getUseTrickleIce(), ); // Convert AudioCodec objects to Map format for the peer connection List>? codecMaps; @@ -1383,6 +1390,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), + getUseTrickleIce(), ); // Convert AudioCodec objects to Map format for the peer connection From 7f9dede887532db8b1934d60679ca904328c4230 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 11 Sep 2025 10:01:03 +0000 Subject: [PATCH 02/11] WEBRTC-2963: Refactor trickle ICE to use method parameters instead of config - Remove useTrickleIce from Config classes (base Config, CredentialConfig, TokenConfig) - Remove getUseTrickleIce() method from TelnyxClient - Add useTrickleIce parameter to newInvite() and acceptCall() methods - Update Peer constructor calls to pass useTrickleIce directly - Update demo app to pass useTrickleIce from profile when calling methods - Keep existing socket methods and SDP modifications for trickle ICE support --- lib/view/telnyx_client_view_model.dart | 4 ++++ packages/telnyx_webrtc/lib/config/telnyx_config.dart | 12 ------------ packages/telnyx_webrtc/lib/telnyx_client.dart | 12 +++++------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index 2f3c10ee..beccd944 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -764,6 +764,7 @@ class TelnyxClientViewModel with ChangeNotifier { } void call(String destination) { + final profile = Provider.of(context, listen: false).selectedProfile; _currentCall = _telnyxClient.newInvite( _localName, _localNumber, @@ -772,6 +773,7 @@ class TelnyxClientViewModel with ChangeNotifier { customHeaders: {'X-Header-1': 'Value1', 'X-Header-2': 'Value2'}, preferredCodecs: _preferredCodecs.isNotEmpty ? _preferredCodecs : null, debug: true, + useTrickleIce: profile.useTrickleIce, ); logger.i( @@ -881,6 +883,7 @@ class TelnyxClientViewModel with ChangeNotifier { ); } + final profile = Provider.of(context, listen: false).selectedProfile; _currentCall = _telnyxClient.acceptCall( invite, _localName, @@ -889,6 +892,7 @@ class TelnyxClientViewModel with ChangeNotifier { customHeaders: {}, preferredCodecs: _preferredCodecs.isNotEmpty ? _preferredCodecs : null, debug: true, + useTrickleIce: profile.useTrickleIce, ); observeCurrentCall(); diff --git a/packages/telnyx_webrtc/lib/config/telnyx_config.dart b/packages/telnyx_webrtc/lib/config/telnyx_config.dart index 93d600ae..5d607ae3 100644 --- a/packages/telnyx_webrtc/lib/config/telnyx_config.dart +++ b/packages/telnyx_webrtc/lib/config/telnyx_config.dart @@ -19,7 +19,6 @@ class Config { this.region = Region.auto, this.fallbackOnRegionFailure = true, this.forceRelayCandidate = false, - this.useTrickleIce = false, }); /// Name associated with the SIP account @@ -66,13 +65,6 @@ class Config { /// as all media will be relayed through TURN servers. /// - Important: This setting is disabled by default to maintain optimal call quality. final bool forceRelayCandidate; - - /// Controls whether the SDK should use Trickle ICE for peer connections. - /// When enabled, ICE candidates are sent individually as they are discovered, - /// allowing for faster call establishment. When disabled, the SDK waits for - /// all ICE candidates to be gathered before sending the offer/answer. - /// - Note: This setting is disabled by default to maintain compatibility. - final bool useTrickleIce; } /// Creates an instance of CredentialConfig which can be used to log in @@ -88,7 +80,6 @@ class Config { /// [ringbackPath] is the path to the ringback file (audio to play when calling) /// [customLogger] is a custom logger to use for logging - if left null the default logger will be used which uses the Logger package /// [forceRelayCandidate] controls whether the SDK should force TURN relay for peer connections (default: false) -/// [useTrickleIce] controls whether the SDK should use Trickle ICE for peer connections (default: false) class CredentialConfig extends Config { /// Creates an instance of CredentialConfig which can be used to log in /// @@ -118,7 +109,6 @@ class CredentialConfig extends Config { super.region = Region.auto, super.fallbackOnRegionFailure = true, super.forceRelayCandidate = false, - super.useTrickleIce = false, }); /// SIP username to log in with. Either a SIP Credential from the Portal or a Generated Credential from the API @@ -141,7 +131,6 @@ class CredentialConfig extends Config { /// [ringbackPath] is the path to the ringback file (audio to play when calling) /// [customLogger] is a custom logger to use for logging - if left null the default logger will be used which uses the Logger package /// [forceRelayCandidate] controls whether the SDK should force TURN relay for peer connections (default: false) -/// [useTrickleIce] controls whether the SDK should use Trickle ICE for peer connections (default: false) class TokenConfig extends Config { /// Creates an instance of TokenConfig which can be used to log in /// @@ -170,7 +159,6 @@ class TokenConfig extends Config { super.region = Region.auto, super.fallbackOnRegionFailure = true, super.forceRelayCandidate = false, - super.useTrickleIce = false, }); /// Token to log in with. The token would be generated from a Generated Credential via the API diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index 4d6eef4e..f60902f5 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -249,11 +249,7 @@ class TelnyxClient { return config?.forceRelayCandidate ?? false; } - /// Returns the useTrickleIce setting from the current config - bool getUseTrickleIce() { - final config = _storedCredentialConfig ?? _storedTokenConfig; - return config?.useTrickleIce ?? false; - } + /// Returns whether or not the client is connected to the socket connection bool isConnected() { @@ -1303,6 +1299,7 @@ class TelnyxClient { Map customHeaders = const {}, List? preferredCodecs, bool debug = false, + bool useTrickleIce = false, }) { final Call inviteCall = _createCall() ..sessionCallerName = callerName @@ -1320,7 +1317,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), - getUseTrickleIce(), + useTrickleIce, ); // Convert AudioCodec objects to Map format for the peer connection List>? codecMaps; @@ -1373,6 +1370,7 @@ class TelnyxClient { Map customHeaders = const {}, List? preferredCodecs, bool debug = false, + bool useTrickleIce = false, }) { final Call answerCall = getCallOrNull(invite.callID!) ?? _createCall() ..callId = invite.callID @@ -1390,7 +1388,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), - getUseTrickleIce(), + useTrickleIce, ); // Convert AudioCodec objects to Map format for the peer connection From 0069c7345d533cda6f254c28e48bd23eea3dfefa Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 11 Sep 2025 14:06:05 +0100 Subject: [PATCH 03/11] fix: remove trickleIce implementation on the profile side --- lib/model/profile_model.dart | 6 ------ lib/provider/profile_provider.dart | 22 ---------------------- lib/utils/config_helper.dart | 4 ---- lib/view/screen/home_screen.dart | 17 +++++++++++------ lib/view/telnyx_client_view_model.dart | 15 ++++++++++----- 5 files changed, 21 insertions(+), 43 deletions(-) diff --git a/lib/model/profile_model.dart b/lib/model/profile_model.dart index 92f0955e..de606f4b 100644 --- a/lib/model/profile_model.dart +++ b/lib/model/profile_model.dart @@ -22,7 +22,6 @@ class Profile { final Region region; final bool fallbackOnRegionFailure; final bool forceRelayCandidate; - final bool useTrickleIce; Profile({ required this.isTokenLogin, @@ -36,7 +35,6 @@ class Profile { this.region = Region.auto, this.fallbackOnRegionFailure = true, this.forceRelayCandidate = false, - this.useTrickleIce = false, }); factory Profile.fromJson(Map json) { @@ -55,7 +53,6 @@ class Profile { ), fallbackOnRegionFailure: json['fallbackOnRegionFailure'] as bool? ?? true, forceRelayCandidate: json['forceRelayCandidate'] as bool? ?? false, - useTrickleIce: json['useTrickleIce'] as bool? ?? false, ); } @@ -72,7 +69,6 @@ class Profile { 'region': region.value, 'fallbackOnRegionFailure': fallbackOnRegionFailure, 'forceRelayCandidate': forceRelayCandidate, - 'useTrickleIce': useTrickleIce, }; } @@ -88,7 +84,6 @@ class Profile { Region? region, bool? fallbackOnRegionFailure, bool? forceRelayCandidate, - bool? useTrickleIce, }) { return Profile( isTokenLogin: isTokenLogin ?? this.isTokenLogin, @@ -103,7 +98,6 @@ class Profile { fallbackOnRegionFailure: fallbackOnRegionFailure ?? this.fallbackOnRegionFailure, forceRelayCandidate: forceRelayCandidate ?? this.forceRelayCandidate, - useTrickleIce: useTrickleIce ?? this.useTrickleIce, ); } diff --git a/lib/provider/profile_provider.dart b/lib/provider/profile_provider.dart index 0ef1a604..6ea8df7f 100644 --- a/lib/provider/profile_provider.dart +++ b/lib/provider/profile_provider.dart @@ -124,26 +124,4 @@ class ProfileProvider with ChangeNotifier { notifyListeners(); } } - - Future toggleTrickleIce() async { - if (_selectedProfile != null) { - final updatedProfile = _selectedProfile!.copyWith( - useTrickleIce: !_selectedProfile!.useTrickleIce, - ); - - // Update the profile in the list - final index = _profiles.indexWhere( - (p) => p.sipCallerIDName == _selectedProfile!.sipCallerIDName, - ); - if (index != -1) { - _profiles[index] = updatedProfile; - } - - // Update the selected profile - _selectedProfile = updatedProfile; - - await _saveProfiles(); - notifyListeners(); - } - } } diff --git a/lib/utils/config_helper.dart b/lib/utils/config_helper.dart index 7c584cde..ba8b5895 100644 --- a/lib/utils/config_helper.dart +++ b/lib/utils/config_helper.dart @@ -17,7 +17,6 @@ class ConfigHelper { final sipNumber = prefs.getString('sipNumber'); final notificationToken = prefs.getString('notificationToken'); final forceRelayCandidate = prefs.getBool('forceRelayCandidate') ?? false; - final useTrickleIce = prefs.getBool('useTrickleIce') ?? false; if (sipUser != null && sipPassword != null && @@ -34,7 +33,6 @@ class ConfigHelper { debug: false, reconnectionTimeout: 30000, forceRelayCandidate: forceRelayCandidate, - useTrickleIce: useTrickleIce, ); } } catch (e) { @@ -55,7 +53,6 @@ class ConfigHelper { final sipNumber = prefs.getString('sipNumber'); final notificationToken = prefs.getString('notificationToken'); final forceRelayCandidate = prefs.getBool('forceRelayCandidate') ?? false; - final useTrickleIce = prefs.getBool('useTrickleIce') ?? false; if (token != null && sipName != null && sipNumber != null) { return TokenConfig( @@ -67,7 +64,6 @@ class ConfigHelper { customLogger: CustomSDKLogger(), debug: false, forceRelayCandidate: forceRelayCandidate, - useTrickleIce: useTrickleIce, ); } } catch (e) { diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index a5b7bb4a..02609a78 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -119,7 +119,8 @@ class _HomeScreenState extends State { break; case 'Enable Trickle ICE': case 'Disable Trickle ICE': - Provider.of(context, listen: false).toggleTrickleIce(); + Provider.of(context, listen: false) + .toggleTrickleIce(); break; case 'Assistant Login': _showAssistantLoginDialog(); @@ -145,6 +146,9 @@ class _HomeScreenState extends State { final profileProvider = context.watch(); final selectedProfile = profileProvider.selectedProfile; + final clientViewModel = context.watch(); + final useTrickleIce = clientViewModel.useTrickleIce; + final errorMessage = context.select( (viewModel) => viewModel.errorDialogMessage, ); @@ -178,10 +182,14 @@ class _HomeScreenState extends State { PopupMenuButton( onSelected: handleOptionClick, itemBuilder: (BuildContext context) { + final trickleIceToggleText = useTrickleIce + ? 'Disable Trickle ICE' + : 'Enable Trickle ICE'; return { 'Audio Codecs', 'Export Logs', - 'Disable Push Notifications' + 'Disable Push Notifications', + trickleIceToggleText, }.map(( String choice, ) { @@ -200,10 +208,7 @@ class _HomeScreenState extends State { final debugToggleText = selectedProfile.isDebug ? 'Disable Debugging' : 'Enable Debugging'; - final trickleIceToggleText = selectedProfile.useTrickleIce - ? 'Disable Trickle ICE' - : 'Enable Trickle ICE'; - return {'Export Logs', debugToggleText, trickleIceToggleText, 'Assistant Login'} + return {'Export Logs', debugToggleText, 'Assistant Login'} .map((String choice) { return PopupMenuItem( value: choice, diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index beccd944..35ad75a0 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -52,6 +52,7 @@ class TelnyxClientViewModel with ChangeNotifier { bool _mute = false; bool _hold = false; bool _isAssistantMode = false; + bool _useTrickleIce = false; List _supportedCodecs = []; List _preferredCodecs = []; @@ -122,6 +123,13 @@ class TelnyxClientViewModel with ChangeNotifier { return _isAssistantMode; } + bool get useTrickleIce => _useTrickleIce; + + void toggleTrickleIce() { + _useTrickleIce = !_useTrickleIce; + notifyListeners(); + } + List get supportedCodecs => _supportedCodecs; List get preferredCodecs => _preferredCodecs; @@ -380,7 +388,6 @@ class TelnyxClientViewModel with ChangeNotifier { await prefs.setString('notificationToken', config.notificationToken!); } await prefs.setBool('forceRelayCandidate', config.forceRelayCandidate); - await prefs.setBool('useTrickleIce', config.useTrickleIce); } Future _clearConfigForAutoLogin() async { @@ -764,7 +771,6 @@ class TelnyxClientViewModel with ChangeNotifier { } void call(String destination) { - final profile = Provider.of(context, listen: false).selectedProfile; _currentCall = _telnyxClient.newInvite( _localName, _localNumber, @@ -773,7 +779,7 @@ class TelnyxClientViewModel with ChangeNotifier { customHeaders: {'X-Header-1': 'Value1', 'X-Header-2': 'Value2'}, preferredCodecs: _preferredCodecs.isNotEmpty ? _preferredCodecs : null, debug: true, - useTrickleIce: profile.useTrickleIce, + useTrickleIce: _useTrickleIce, ); logger.i( @@ -883,7 +889,6 @@ class TelnyxClientViewModel with ChangeNotifier { ); } - final profile = Provider.of(context, listen: false).selectedProfile; _currentCall = _telnyxClient.acceptCall( invite, _localName, @@ -892,7 +897,7 @@ class TelnyxClientViewModel with ChangeNotifier { customHeaders: {}, preferredCodecs: _preferredCodecs.isNotEmpty ? _preferredCodecs : null, debug: true, - useTrickleIce: profile.useTrickleIce, + useTrickleIce: _useTrickleIce, ); observeCurrentCall(); From 7031eb0041397177df5e9f09a7b48028402b128d Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 12 Sep 2025 11:32:19 +0100 Subject: [PATCH 04/11] fix: useTrickleIce on call and accept call methods + align web / and mobile peers --- packages/telnyx_webrtc/lib/call.dart | 3 + .../send/invite_answer_message_body.dart | 8 +- packages/telnyx_webrtc/lib/peer/peer.dart | 289 +++++++++++--- packages/telnyx_webrtc/lib/peer/web/peer.dart | 364 ++++++++++++++---- packages/telnyx_webrtc/lib/telnyx_client.dart | 68 +++- 5 files changed, 607 insertions(+), 125 deletions(-) diff --git a/packages/telnyx_webrtc/lib/call.dart b/packages/telnyx_webrtc/lib/call.dart index 93402ae6..87f10d17 100644 --- a/packages/telnyx_webrtc/lib/call.dart +++ b/packages/telnyx_webrtc/lib/call.dart @@ -241,6 +241,7 @@ class Call { /// @param isAttach Whether this is an attach operation /// @param customHeaders Optional custom SIP headers /// @param debug Whether to enable call quality metrics (default: false) + /// @param useTrickleIce Whether to use trickle ICE for faster call setup (default: false) Call acceptCall( IncomingInviteParams invite, String callerName, @@ -249,6 +250,7 @@ class Call { bool isAttach = false, Map customHeaders = const {}, bool debug = false, + bool useTrickleIce = false, }) { // Store the session information for later use sessionCallerName = callerName; @@ -265,6 +267,7 @@ class Call { customHeaders: customHeaders, isAttach: isAttach, debug: debug, + useTrickleIce: useTrickleIce, ); } diff --git a/packages/telnyx_webrtc/lib/model/verto/send/invite_answer_message_body.dart b/packages/telnyx_webrtc/lib/model/verto/send/invite_answer_message_body.dart index b81db8f3..3107cdd8 100644 --- a/packages/telnyx_webrtc/lib/model/verto/send/invite_answer_message_body.dart +++ b/packages/telnyx_webrtc/lib/model/verto/send/invite_answer_message_body.dart @@ -31,8 +31,10 @@ class InviteParams { String? sdp; String? sessid; String? userAgent; + bool? trickle; - InviteParams({this.dialogParams, this.sdp, this.sessid, this.userAgent}); + InviteParams( + {this.dialogParams, this.sdp, this.sessid, this.userAgent, this.trickle}); InviteParams.fromJson(Map json) { dialogParams = json['dialogParams'] != null @@ -41,6 +43,7 @@ class InviteParams { sdp = json['sdp']; sessid = json['sessid']; userAgent = json['User-Agent']; + trickle = json['trickle']; } Map toJson() { @@ -51,6 +54,9 @@ class InviteParams { data['sdp'] = sdp; data['sessid'] = sessid; data['User-Agent'] = userAgent; + if (trickle != null) { + data['trickle'] = trickle; + } return data; } } diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index b05ba445..c856b8dd 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -28,7 +28,8 @@ class Peer { RTCPeerConnection? peerConnection; /// The constructor for the Peer class. - Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, this._useTrickleIce); + Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, + this._useTrickleIce); final String _selfId = randomNumeric(6); @@ -222,14 +223,16 @@ class Peer { session.remoteCandidates.clear(); } - await Future.delayed(const Duration(milliseconds: 500)); + // With trickle ICE, send offer immediately without waiting for candidates + if (_useTrickleIce) { + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); + // Add trickle ICE capability to SDP + sdpUsed = _addTrickleIceToSdp(sdpUsed ?? ''); - Timer(const Duration(milliseconds: 500), () async { final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -252,6 +255,7 @@ class Peer { sdp: sdpUsed, sessid: sessionId, userAgent: userAgent, + trickle: true, // Set trickle flag ); final inviteMessage = InviteAnswerMessage( id: const Uuid().v4(), @@ -261,9 +265,55 @@ class Peer { ); final String jsonInviteMessage = jsonEncode(inviteMessage); - + GlobalLogger() + .i('Peer :: Sending INVITE with trickle ICE enabled (immediate)'); _send(jsonInviteMessage); - }); + } else { + // Traditional ICE gathering - wait for candidates + await Future.delayed(const Duration(milliseconds: 500)); + + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); + + Timer(const Duration(milliseconds: 500), () async { + final userAgent = await VersionUtils.getUserAgent(); + final dialogParams = DialogParams( + attach: false, + audio: true, + callID: callId, + callerIdName: callerName, + callerIdNumber: callerNumber, + clientState: clientState, + destinationNumber: destinationNumber, + remoteCallerIdName: '', + screenShare: false, + useStereo: false, + userVariables: [], + video: false, + customHeaders: customHeaders, + preferredCodecs: preferredCodecs, + ); + final inviteParams = InviteParams( + dialogParams: dialogParams, + sdp: sdpUsed, + sessid: sessionId, + userAgent: userAgent, + trickle: false, // Set trickle flag to false for traditional ICE + ); + final inviteMessage = InviteAnswerMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: SocketMethod.invite, + params: inviteParams, + ); + + final String jsonInviteMessage = jsonEncode(inviteMessage); + + _send(jsonInviteMessage); + }); + } } catch (e) { GlobalLogger().e('Peer :: $e'); } @@ -348,21 +398,31 @@ class Peer { 'Peer :: onIceCandidate in _createAnswer received: ${candidate.candidate}', ); if (candidate.candidate != null) { - final candidateString = candidate.candidate.toString(); - final isValidCandidate = - candidateString.contains('stun.telnyx.com') || - candidateString.contains('turn.telnyx.com'); - - if (isValidCandidate) { - GlobalLogger().i('Peer :: Valid ICE candidate: $candidateString'); - // Only add valid candidates and reset timer - await session.peerConnection?.addCandidate(candidate); - _lastCandidateTime = DateTime.now(); + if (_useTrickleIce) { + // With trickle ICE, send all candidates immediately + _sendTrickleCandidate(candidate, callId); } else { - GlobalLogger().i( - 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', - ); + // Traditional ICE: filter and collect candidates + final candidateString = candidate.candidate.toString(); + final isValidCandidate = + candidateString.contains('stun.telnyx.com') || + candidateString.contains('turn.telnyx.com'); + + if (isValidCandidate) { + GlobalLogger() + .i('Peer :: Valid ICE candidate: $candidateString'); + // Only add valid candidates and reset timer + await session.peerConnection?.addCandidate(candidate); + _lastCandidateTime = DateTime.now(); + } else { + GlobalLogger().i( + 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', + ); + } } + } else if (_useTrickleIce) { + // End of candidates signal for trickle ICE + _sendEndOfCandidates(callId); } } else { // Still collect candidates if peerConnection is not ready yet @@ -374,14 +434,16 @@ class Peer { await session.peerConnection!.createAnswer(_dcConstraints); await session.peerConnection!.setLocalDescription(s); - // Start ICE candidate gathering and wait for negotiation to complete - _lastCandidateTime = DateTime.now(); - _setOnNegotiationComplete(() async { + if (_useTrickleIce) { + // With trickle ICE, send answer immediately without waiting for candidates String? sdpUsed = ''; await session.peerConnection?.getLocalDescription().then( (value) => sdpUsed = value?.sdp.toString(), ); + // Add trickle ICE capability to SDP + sdpUsed = _addTrickleIceToSdp(sdpUsed ?? ''); + final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -404,6 +466,7 @@ class Peer { sdp: sdpUsed, sessid: session.sid, userAgent: userAgent, + trickle: true, // Set trickle flag ); final answerMessage = InviteAnswerMessage( id: const Uuid().v4(), @@ -413,8 +476,53 @@ class Peer { ); final String jsonAnswerMessage = jsonEncode(answerMessage); + GlobalLogger() + .i('Peer :: Sending ANSWER with trickle ICE enabled (immediate)'); _send(jsonAnswerMessage); - }); + } else { + // Traditional ICE gathering - wait for candidates + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); + + final userAgent = await VersionUtils.getUserAgent(); + final dialogParams = DialogParams( + attach: false, + audio: true, + callID: callId, + callerIdName: callerNumber, + callerIdNumber: callerNumber, + clientState: clientState, + destinationNumber: destinationNumber, + remoteCallerIdName: '', + screenShare: false, + useStereo: false, + userVariables: [], + video: false, + customHeaders: customHeaders, + preferredCodecs: preferredCodecs, + ); + final inviteParams = InviteParams( + dialogParams: dialogParams, + sdp: sdpUsed, + sessid: session.sid, + userAgent: userAgent, + trickle: false, // Set trickle flag to false for traditional ICE + ); + final answerMessage = InviteAnswerMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: isAttach ? SocketMethod.attach : SocketMethod.answer, + params: inviteParams, + ); + + final String jsonAnswerMessage = jsonEncode(answerMessage); + _send(jsonAnswerMessage); + }); + } } catch (e) { GlobalLogger().e('Peer :: $e'); } @@ -496,24 +604,27 @@ class Peer { 'Peer :: onIceCandidate in _createSession received: ${candidate.candidate}', ); if (candidate.candidate != null) { - final candidateString = candidate.candidate.toString(); - final isValidCandidate = candidateString.contains('stun.telnyx.com') || - candidateString.contains('turn.telnyx.com'); - - if (isValidCandidate) { - GlobalLogger().i('Peer :: Valid ICE candidate: $candidateString'); - - if (_useTrickleIce) { - // Send candidate immediately via trickle ICE - _sendTrickleCandidate(candidate, callId); - } else { + if (_useTrickleIce) { + // With trickle ICE, send ALL candidates immediately (host, srflx, relay) + GlobalLogger().i( + 'Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + _sendTrickleCandidate(candidate, callId); + } else { + // Traditional ICE: filter and collect candidates + final candidateString = candidate.candidate.toString(); + final isValidCandidate = + candidateString.contains('stun.telnyx.com') || + candidateString.contains('turn.telnyx.com'); + + if (isValidCandidate) { + GlobalLogger().i('Peer :: Valid ICE candidate: $candidateString'); // Add valid candidates for traditional ICE gathering await peerConnection?.addCandidate(candidate); + } else { + GlobalLogger().i( + 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', + ); } - } else { - GlobalLogger().i( - 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', - ); } } else { GlobalLogger().i('Peer :: onIceCandidate: complete!'); @@ -708,14 +819,56 @@ class Peer { _negotiationTimer = null; } + /// Adds trickle ICE capability to the SDP + String _addTrickleIceToSdp(String sdp) { + if (!_useTrickleIce) { + return sdp; + } + + // Check if ice-options:trickle already exists + if (sdp.contains('a=ice-options:trickle')) { + return sdp; + } + + // Find the first media line (m=) and add ice-options after it + final lines = sdp.split('\r\n'); + final modifiedLines = []; + bool addedTrickleIce = false; + + for (int i = 0; i < lines.length; i++) { + modifiedLines.add(lines[i]); + + // Add ice-options:trickle after the first m= line + if (!addedTrickleIce && lines[i].startsWith('m=')) { + // Look for the next line that starts with 'a=' and add before it + // or add immediately after the m= line + int insertIndex = i + 1; + while ( + insertIndex < lines.length && lines[insertIndex].startsWith('c=')) { + modifiedLines.add(lines[insertIndex]); + insertIndex++; + i++; + } + modifiedLines.add('a=ice-options:trickle'); + addedTrickleIce = true; + } + } + + final modifiedSdp = modifiedLines.join('\r\n'); + GlobalLogger().i('Peer :: Added trickle ICE to SDP'); + return modifiedSdp; + } + /// Sends a trickle ICE candidate to the remote peer void _sendTrickleCandidate(RTCIceCandidate candidate, String callId) { try { + // Ensure sdpMid and sdpMLineIndex are set correctly for audio m-line final candidateParams = CandidateParams( dialogParams: CandidateDialogParams(callID: callId), candidate: candidate.candidate, - sdpMid: candidate.sdpMid, - sdpMLineIndex: candidate.sdpMLineIndex, + sdpMid: candidate.sdpMid ?? '0', // Default to '0' for audio m-line + sdpMLineIndex: + candidate.sdpMLineIndex ?? 0, // Default to 0 for audio m-line ); final candidateMessage = CandidateMessage( @@ -726,7 +879,8 @@ class Peer { ); final String jsonCandidateMessage = jsonEncode(candidateMessage); - GlobalLogger().i('Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + GlobalLogger().i( + 'Peer :: Sending trickle ICE candidate: ${candidate.candidate} (sdpMid: ${candidateParams.sdpMid}, sdpMLineIndex: ${candidateParams.sdpMLineIndex})'); _send(jsonCandidateMessage); } catch (e) { GlobalLogger().e('Peer :: Error sending trickle ICE candidate: $e'); @@ -747,11 +901,56 @@ class Peer { params: endOfCandidatesParams, ); - final String jsonEndOfCandidatesMessage = jsonEncode(endOfCandidatesMessage); + final String jsonEndOfCandidatesMessage = + jsonEncode(endOfCandidatesMessage); GlobalLogger().i('Peer :: Sending end of candidates signal'); _send(jsonEndOfCandidatesMessage); } catch (e) { GlobalLogger().e('Peer :: Error sending end of candidates: $e'); } } + + /// Handles a remote ICE candidate received via trickle ICE + void handleRemoteCandidate( + String callId, String candidateStr, String sdpMid, int sdpMLineIndex) { + try { + GlobalLogger().i( + 'Peer :: Handling remote candidate for call $callId: $candidateStr'); + + // Find the session for this call + final Session? session = _sessions[_selfId]; + + if (session != null && session.peerConnection != null) { + // Create RTCIceCandidate from the received candidate string + final candidate = RTCIceCandidate( + candidateStr, + sdpMid, + sdpMLineIndex, + ); + + // Add the candidate to the peer connection + session.peerConnection!.addCandidate(candidate).then((_) { + GlobalLogger().i('Peer :: Successfully added remote candidate'); + }).catchError((error) { + GlobalLogger().e('Peer :: Error adding remote candidate: $error'); + }); + } else { + GlobalLogger().w( + 'Peer :: No session or peer connection available for call $callId'); + // Store the candidate for later if session is not ready yet + final Session? pendingSession = _sessions[_selfId]; + if (pendingSession != null) { + pendingSession.remoteCandidates.add(RTCIceCandidate( + candidateStr, + sdpMid, + sdpMLineIndex, + )); + GlobalLogger() + .i('Peer :: Stored remote candidate for later processing'); + } + } + } catch (e) { + GlobalLogger().e('Peer :: Error handling remote candidate: $e'); + } + } } diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 87468c55..9d2daeda 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -9,6 +9,8 @@ import 'package:telnyx_webrtc/model/jsonrpc.dart'; import 'package:telnyx_webrtc/model/socket_method.dart'; import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; import 'package:telnyx_webrtc/model/verto/send/invite_answer_message_body.dart'; +import 'package:telnyx_webrtc/model/verto/send/candidate_message_body.dart'; +import 'package:telnyx_webrtc/model/verto/send/end_of_candidates_message_body.dart'; import 'package:telnyx_webrtc/peer/session.dart'; import 'package:telnyx_webrtc/peer/signaling_state.dart'; import 'package:telnyx_webrtc/telnyx_client.dart'; @@ -22,13 +24,18 @@ import 'package:uuid/uuid.dart'; /// Represents a peer in the WebRTC communication. class Peer { + /// The peer connection instance. + RTCPeerConnection? peerConnection; + /// The constructor for the Peer class. - Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate); + Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, + this._useTrickleIce); final TxSocket _socket; final TelnyxClient _txClient; final bool _debug; final bool _forceRelayCandidate; + final bool _useTrickleIce; /// Random numeric ID for this peer (like the mobile version). final String _selfId = randomNumeric(6); @@ -74,7 +81,12 @@ class Peer { final Map _iceServers = { 'iceServers': [ { - 'urls': [DefaultConfig.defaultStun, DefaultConfig.defaultTurn], + 'url': DefaultConfig.defaultStun, + 'username': DefaultConfig.username, + 'credential': DefaultConfig.password, + }, + { + 'url': DefaultConfig.defaultTurn, 'username': DefaultConfig.username, 'credential': DefaultConfig.password, }, @@ -161,6 +173,7 @@ class Peer { /// [callId] The unique ID for this call. /// [telnyxSessionId] The Telnyx session ID. /// [customHeaders] Custom headers to include in the invite. + /// [useTrickleIce] Whether to use trickle ICE for this call. Future invite( String callerName, String callerNumber, @@ -168,8 +181,9 @@ class Peer { String clientState, String callId, String telnyxSessionId, - Map customHeaders, - ) async { + Map customHeaders, { + bool useTrickleIce = false, + }) async { final sessionId = _selfId; final session = await _createSession( null, @@ -191,6 +205,7 @@ class Peer { callId, telnyxSessionId, customHeaders, + useTrickleIce, ); // Indicate a new outbound call is created @@ -207,6 +222,7 @@ class Peer { String callId, String telnyxSessionId, Map customHeaders, + bool useTrickleIce, ) async { try { final description = await session.peerConnection!.createOffer( @@ -214,6 +230,14 @@ class Peer { ); await session.peerConnection!.setLocalDescription(description); + // For trickle ICE, modify SDP to include trickle support + if (_useTrickleIce) { + final modifiedSdp = _addTrickleIceToSdp(description.sdp!); + final modifiedDescription = + RTCSessionDescription(modifiedSdp, description.type!); + await session.peerConnection!.setLocalDescription(modifiedDescription); + } + // Add any remote candidates that arrived early if (session.remoteCandidates.isNotEmpty) { for (var candidate in session.remoteCandidates) { @@ -225,8 +249,20 @@ class Peer { session.remoteCandidates.clear(); } - // Give the localDescription a moment to be set - await Future.delayed(const Duration(milliseconds: 500)); + // For trickle ICE, set up immediate candidate sending + if (_useTrickleIce) { + session.peerConnection?.onIceCandidate = (candidate) async { + if (candidate.candidate != null) { + GlobalLogger().i( + 'Trickle ICE: Sending candidate immediately: ${candidate.candidate}', + ); + _sendCandidate(callId, candidate); + } else { + GlobalLogger().i('Trickle ICE: Sending end of candidates'); + _sendEndOfCandidates(callId); + } + }; + } String? sdpUsed = ''; final localDesc = await session.peerConnection?.getLocalDescription(); @@ -234,8 +270,8 @@ class Peer { sdpUsed = localDesc.sdp; } - // Send INVITE - Timer(const Duration(milliseconds: 500), () async { + // Send INVITE immediately for trickle ICE, or after delay for regular ICE + final sendInvite = () async { final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -258,6 +294,7 @@ class Peer { sdp: sdpUsed, sessid: session.sid, userAgent: userAgent, + trickle: useTrickleIce, ); final inviteMessage = InviteAnswerMessage( @@ -269,7 +306,16 @@ class Peer { final String jsonInviteMessage = jsonEncode(inviteMessage); _send(jsonInviteMessage); - }); + }; + + if (_useTrickleIce) { + // Send INVITE immediately for trickle ICE + await sendInvite(); + } else { + // Wait for ICE gathering to complete for regular ICE + await Future.delayed(const Duration(milliseconds: 500)); + Timer(const Duration(milliseconds: 500), sendInvite); + } } catch (e) { GlobalLogger().e('Peer :: _createOffer error: $e'); } @@ -298,6 +344,7 @@ class Peer { /// [invite] The incoming invite parameters containing the SDP offer. /// [customHeaders] Custom headers to include in the answer. /// [isAttach] Whether this is an attach call. + /// [preferredCodecs] Optional list of preferred audio codecs. Future accept( String callerName, String callerNumber, @@ -306,8 +353,9 @@ class Peer { String callId, IncomingInviteParams invite, Map customHeaders, - bool isAttach, - ) async { + bool isAttach, { + List>? preferredCodecs, + }) async { final sessionId = _selfId; final session = await _createSession( null, @@ -335,6 +383,7 @@ class Peer { callId, customHeaders, isAttach, + preferredCodecs, ); // Indicate the call is now active (in mobile code, we do this after answer). @@ -351,35 +400,55 @@ class Peer { String callId, Map customHeaders, bool isAttach, + List>? preferredCodecs, ) async { try { - // ICE candidate callback (with optional skipping logic) - session.peerConnection?.onIceCandidate = (candidate) async { - GlobalLogger().i( - 'Web Peer :: onIceCandidate in _createAnswer received: ${candidate.candidate}', - ); - if (candidate.candidate != null) { - final candidateString = candidate.candidate.toString(); - final isValidCandidate = - candidateString.contains('stun.telnyx.com') || - candidateString.contains('turn.telnyx.com'); - - if (isValidCandidate) { + // ICE candidate callback + if (_useTrickleIce) { + // For trickle ICE, send candidates immediately + session.peerConnection?.onIceCandidate = (candidate) async { + GlobalLogger().i( + 'Trickle ICE :: onIceCandidate in _createAnswer: ${candidate.candidate}', + ); + if (candidate.candidate != null) { GlobalLogger().i( - 'Web Peer :: Valid ICE candidate: $candidateString', + 'Trickle ICE: Sending candidate immediately: ${candidate.candidate}', ); - // Only add valid candidates and reset timer - await session.peerConnection?.addCandidate(candidate); - _lastCandidateTime = DateTime.now(); + _sendCandidate(callId, candidate); } else { - GlobalLogger().i( - 'Web Peer :: Ignoring non-STUN/TURN candidate: $candidateString', - ); + GlobalLogger().i('Trickle ICE: Sending end of candidates'); + _sendEndOfCandidates(callId); } - } else { - GlobalLogger().i('Web Peer :: onIceCandidate: complete'); - } - }; + }; + } else { + // Original ICE candidate callback for non-trickle ICE + session.peerConnection?.onIceCandidate = (candidate) async { + GlobalLogger().i( + 'Web Peer :: onIceCandidate in _createAnswer received: ${candidate.candidate}', + ); + if (candidate.candidate != null) { + final candidateString = candidate.candidate.toString(); + final isValidCandidate = + candidateString.contains('stun.telnyx.com') || + candidateString.contains('turn.telnyx.com'); + + if (isValidCandidate) { + GlobalLogger().i( + 'Web Peer :: Valid ICE candidate: $candidateString', + ); + // Only add valid candidates and reset timer + await session.peerConnection?.addCandidate(candidate); + _lastCandidateTime = DateTime.now(); + } else { + GlobalLogger().i( + 'Web Peer :: Ignoring non-STUN/TURN candidate: $candidateString', + ); + } + } else { + GlobalLogger().i('Web Peer :: onIceCandidate: complete'); + } + }; + } // Create and set local description final description = await session.peerConnection!.createAnswer( @@ -387,54 +456,100 @@ class Peer { ); await session.peerConnection!.setLocalDescription(description); - // Start ICE candidate gathering and wait for negotiation to complete - _lastCandidateTime = DateTime.now(); - _setOnNegotiationComplete(() async { - String? sdpUsed = ''; - final localDesc = await session.peerConnection?.getLocalDescription(); - if (localDesc != null) { - sdpUsed = localDesc.sdp; - } - - final userAgent = await VersionUtils.getUserAgent(); - final dialogParams = DialogParams( - attach: false, - audio: true, - callID: callId, - callerIdName: callerNumber, - callerIdNumber: callerNumber, - clientState: clientState, - destinationNumber: destinationNumber, - remoteCallerIdName: '', - screenShare: false, - useStereo: false, - userVariables: [], - video: false, - customHeaders: customHeaders, - ); - - final inviteParams = InviteParams( - dialogParams: dialogParams, - sdp: sdpUsed, - sessid: session.sid, // We use the session's sid - userAgent: userAgent, - ); + // For trickle ICE, modify SDP to include trickle support + if (_useTrickleIce) { + final modifiedSdp = _addTrickleIceToSdp(description.sdp!); + final modifiedDescription = + RTCSessionDescription(modifiedSdp, description.type!); + await session.peerConnection!.setLocalDescription(modifiedDescription); + } - final answerMessage = InviteAnswerMessage( - id: const Uuid().v4(), - jsonrpc: JsonRPCConstant.jsonrpc, - method: isAttach ? SocketMethod.attach : SocketMethod.answer, - params: inviteParams, + // Handle answer sending based on trickle ICE mode + if (_useTrickleIce) { + // For trickle ICE, send answer immediately + await _sendAnswerMessage( + session, + callId, + callerNumber, + destinationNumber, + clientState, + customHeaders, + isAttach, + preferredCodecs, ); - - final String jsonAnswerMessage = jsonEncode(answerMessage); - _send(jsonAnswerMessage); - }); + } else { + // For regular ICE, start candidate gathering and wait for negotiation to complete + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + await _sendAnswerMessage( + session, + callId, + callerNumber, + destinationNumber, + clientState, + customHeaders, + isAttach, + preferredCodecs, + ); + }); + } } catch (e) { GlobalLogger().e('Peer :: _createAnswer error: $e'); } } + /// Sends the answer message + Future _sendAnswerMessage( + Session session, + String callId, + String callerNumber, + String destinationNumber, + String clientState, + Map customHeaders, + bool isAttach, + List>? preferredCodecs, + ) async { + String? sdpUsed = ''; + final localDesc = await session.peerConnection?.getLocalDescription(); + if (localDesc != null) { + sdpUsed = localDesc.sdp; + } + + final userAgent = await VersionUtils.getUserAgent(); + final dialogParams = DialogParams( + attach: false, + audio: true, + callID: callId, + callerIdName: callerNumber, + callerIdNumber: callerNumber, + clientState: clientState, + destinationNumber: destinationNumber, + remoteCallerIdName: '', + screenShare: false, + useStereo: false, + userVariables: [], + video: false, + customHeaders: customHeaders, + ); + + final inviteParams = InviteParams( + dialogParams: dialogParams, + sdp: sdpUsed, + sessid: session.sid, + userAgent: userAgent, + ); + + final answerMessage = InviteAnswerMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: isAttach ? SocketMethod.attach : SocketMethod.answer, + params: inviteParams, + ); + + final String jsonAnswerMessage = jsonEncode(answerMessage); + _send(jsonAnswerMessage); + } + /// Creates a local media stream (audio only for web). /// /// [media] The type of media to create (currently ignored, defaults to audio). @@ -479,10 +594,11 @@ class Peer { } // Create PeerConnection - final pc = await createPeerConnection({ + peerConnection = await createPeerConnection({ ..._buildIceConfiguration(), ...{'sdpSemantics': sdpSemantics}, }, _dcConstraints); + final pc = peerConnection!; // If we want the same plan-b/unified-plan logic as mobile: if (media != 'data') { @@ -518,7 +634,7 @@ class Peer { } } - // ICE callbacks + // ICE callbacks - this will be overridden in _createOffer/_createAnswer for trickle ICE pc ..onIceCandidate = (candidate) async { GlobalLogger().i( @@ -709,4 +825,98 @@ class Peer { _negotiationTimer?.cancel(); _negotiationTimer = null; } + + /// Adds trickle ICE support to SDP + String _addTrickleIceToSdp(String sdp) { + final lines = sdp.split('\r\n'); + final modifiedLines = []; + bool trickleAdded = false; + + for (final line in lines) { + modifiedLines.add(line); + // Add a=ice-options:trickle after the first m= line + if (line.startsWith('m=') && !trickleAdded) { + modifiedLines.add('a=ice-options:trickle'); + trickleAdded = true; + } + } + + return modifiedLines.join('\r\n'); + } + + /// Sends an ICE candidate via WebSocket + void _sendCandidate(String callId, RTCIceCandidate candidate) { + try { + final candidateParams = CandidateParams( + dialogParams: CandidateDialogParams(callID: callId), + candidate: candidate.candidate, + sdpMid: candidate.sdpMid ?? '0', + sdpMLineIndex: candidate.sdpMLineIndex ?? 0, + ); + + final candidateMessage = CandidateMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: SocketMethod.candidate, + params: candidateParams, + ); + + final String jsonCandidateMessage = jsonEncode(candidateMessage); + GlobalLogger().i( + 'Web Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + _send(jsonCandidateMessage); + } catch (e) { + GlobalLogger().e('Web Peer :: Error sending trickle ICE candidate: $e'); + } + } + + /// Sends end of candidates signal + void _sendEndOfCandidates(String callId) { + try { + final endOfCandidatesParams = EndOfCandidatesParams( + dialogParams: EndOfCandidatesDialogParams(callID: callId), + ); + + final endOfCandidatesMessage = EndOfCandidatesMessage( + id: const Uuid().v4(), + jsonrpc: JsonRPCConstant.jsonrpc, + method: SocketMethod.endOfCandidates, + params: endOfCandidatesParams, + ); + + final String jsonEndOfCandidatesMessage = + jsonEncode(endOfCandidatesMessage); + GlobalLogger().i('Web Peer :: Sending end of candidates signal'); + _send(jsonEndOfCandidatesMessage); + } catch (e) { + GlobalLogger().e('Web Peer :: Error sending end of candidates: $e'); + } + } + + /// Handles remote ICE candidates received via WebSocket + void handleRemoteCandidate( + String callId, + String candidateStr, + String? sdpMid, + int? sdpMLineIndex, + ) async { + final session = _sessions[_selfId]; + if (session?.peerConnection != null) { + try { + final candidate = RTCIceCandidate( + candidateStr, + sdpMid, + sdpMLineIndex, + ); + await session!.peerConnection!.addCandidate(candidate); + GlobalLogger().i( + 'Web Peer :: Added remote candidate: $candidateStr', + ); + } catch (e) { + GlobalLogger().e( + 'Web Peer :: Error adding remote candidate: $e', + ); + } + } + } } diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index f60902f5..fff66f25 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -249,8 +249,6 @@ class TelnyxClient { return config?.forceRelayCandidate ?? false; } - - /// Returns whether or not the client is connected to the socket connection bool isConnected() { return _connected; @@ -1289,6 +1287,10 @@ class TelnyxClient { /// If any codec in the list is not supported by the platform or remote party, /// the system will automatically fall back to a supported codec. /// - [debug]: Enables detailed logging for this specific call if set to true. + /// - [useTrickleIce]: When true, enables trickle ICE for the call. Trickle ICE allows + /// ICE candidates to be sent incrementally as they are discovered, rather than + /// waiting for all candidates to be gathered before sending the SDP. This can + /// significantly reduce call setup time. Defaults to false. /// /// Returns a [Call] object representing the new outgoing call. Call newInvite( @@ -1359,6 +1361,10 @@ class TelnyxClient { /// If any codec in the list is not supported by the platform or remote party, /// the system will automatically fall back to a supported codec. /// - [debug]: Enables detailed logging for this specific call if set to true. + /// - [useTrickleIce]: When true, enables trickle ICE for the call. Trickle ICE allows + /// ICE candidates to be sent incrementally as they are discovered, rather than + /// waiting for all candidates to be gathered before sending the SDP. This can + /// significantly reduce call setup time. Defaults to false. /// /// Returns the [Call] object associated with the accepted call. Call acceptCall( @@ -2102,6 +2108,64 @@ class TelnyxClient { onSocketMessageReceived(message); break; } + case SocketMethod.candidate: + { + GlobalLogger() + .i('TRICKLE ICE CANDIDATE RECEIVED :: $messageJson'); + final Map candidateData = + jsonDecode(data.toString()); + + // Extract candidate information + final String? callId = candidateData['params']?['callID']; + final String? candidateStr = + candidateData['params']?['candidate']; + final String? sdpMid = candidateData['params']?['sdpMid']; + final int? sdpMLineIndex = + candidateData['params']?['sdpMLineIndex']; + + if (callId != null && candidateStr != null) { + // Find the call and add the remote candidate + final Call? call = calls[callId]; + if (call != null && call.peerConnection != null) { + // Add remote candidate to peer connection + call.peerConnection!.handleRemoteCandidate( + callId, + candidateStr, + sdpMid ?? '0', + sdpMLineIndex ?? 0, + ); + } else { + GlobalLogger() + .w('Received candidate for unknown call: $callId'); + } + } + break; + } + case SocketMethod.endOfCandidates: + { + GlobalLogger().i('END OF CANDIDATES RECEIVED :: $messageJson'); + final Map endData = + jsonDecode(data.toString()); + + // Extract call ID + final String? callId = + endData['params']?['dialogParams']?['callID']; + + if (callId != null) { + // Find the call and signal end of candidates + final Call? call = calls[callId]; + if (call != null) { + GlobalLogger() + .i('End of candidates signaled for call: $callId'); + // The peer connection will handle this internally + // No specific action needed as WebRTC handles this automatically + } else { + GlobalLogger().w( + 'Received endOfCandidates for unknown call: $callId'); + } + } + break; + } } } else { GlobalLogger().i('Received and ignored empty packet'); From b0e537f515689e2b408dbb58f73dfd2227d876b7 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 22 Sep 2025 12:00:26 +0100 Subject: [PATCH 05/11] feat: queue incoming candidates and utilise once remote SDP is set. Use timer for endOfCandidate message incase signalling does not complete --- packages/telnyx_webrtc/lib/peer/peer.dart | 156 ++++++++++++++++-- packages/telnyx_webrtc/lib/peer/web/peer.dart | 155 +++++++++++++++-- packages/telnyx_webrtc/lib/telnyx_client.dart | 2 - 3 files changed, 277 insertions(+), 36 deletions(-) diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index c856b8dd..5b6de68b 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -28,8 +28,13 @@ class Peer { RTCPeerConnection? peerConnection; /// The constructor for the Peer class. - Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, - this._useTrickleIce); + Peer( + this._socket, + this._debug, + this._txClient, + this._forceRelayCandidate, + this._useTrickleIce, + ); final String _selfId = randomNumeric(6); @@ -46,6 +51,14 @@ class Peer { static const int _negotiationTimeout = 500; // 500ms timeout for negotiation Function()? _onNegotiationComplete; + // Add trickle ICE end-of-candidates timer fields + Timer? _trickleIceTimer; + DateTime? _lastTrickleCandidateTime; + static const int _trickleIceTimeout = + 3000; // 3 seconds timeout for trickle ICE + String? _currentTrickleCallId; + bool _endOfCandidatesSent = false; + final Map _sessions = {}; MediaStream? _localStream; final List _remoteStreams = []; @@ -326,6 +339,22 @@ class Peer { await _sessions[_selfId]?.peerConnection?.setRemoteDescription( RTCSessionDescription(sdp, 'answer'), ); + + // Process any queued candidates after setting remote SDP + final session = _sessions[_selfId]; + if (session != null && session.remoteCandidates.isNotEmpty) { + GlobalLogger() + .i('Peer :: Processing queued remote candidates after remote SDP'); + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger() + .i('Peer :: Adding queued candidate: ${candidate.candidate}'); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + GlobalLogger().i('Peer :: Cleared queued candidates after processing'); + } } /// Accepts an incoming call. @@ -363,6 +392,23 @@ class Peer { RTCSessionDescription(invite.sdp, 'offer'), ); + // Process any queued candidates after setting remote SDP + if (session.remoteCandidates.isNotEmpty) { + GlobalLogger().i( + 'Peer :: Processing queued remote candidates after setting remote SDP in accept', + ); + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger() + .i('Peer :: Adding queued candidate: ${candidate.candidate}'); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + GlobalLogger() + .i('Peer :: Cleared queued candidates after processing in accept'); + } + await _createAnswer( session, 'audio', @@ -567,10 +613,13 @@ class Peer { final newSession = session ?? Session(sid: sessionId, pid: peerId); if (media != 'data') _localStream = await createStream(media); - peerConnection = await createPeerConnection({ - ..._buildIceConfiguration(), - ...{'sdpSemantics': sdpSemantics}, - }, _dcConstraints); + peerConnection = await createPeerConnection( + { + ..._buildIceConfiguration(), + ...{'sdpSemantics': sdpSemantics}, + }, + _dcConstraints, + ); await startStats(callId, peerId, onCallQualityChange: onCallQualityChange); @@ -607,8 +656,12 @@ class Peer { if (_useTrickleIce) { // With trickle ICE, send ALL candidates immediately (host, srflx, relay) GlobalLogger().i( - 'Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + 'Peer :: Sending trickle ICE candidate: ${candidate.candidate}', + ); _sendTrickleCandidate(candidate, callId); + + // Reset the trickle ICE timer when a candidate is generated + _startTrickleIceTimer(callId); } else { // Traditional ICE: filter and collect candidates final candidateString = candidate.candidate.toString(); @@ -629,8 +682,8 @@ class Peer { } else { GlobalLogger().i('Peer :: onIceCandidate: complete!'); if (_useTrickleIce) { - // Send end of candidates signal - _sendEndOfCandidates(callId); + // Send end of candidates signal when gathering completes naturally + _sendEndOfCandidatesAndCleanup(callId); } } }; @@ -782,6 +835,9 @@ class Peer { await session.peerConnection?.dispose(); await session.dc?.close(); stopStats(session.sid); + + // Clean up trickle ICE timer when session is closed + _stopTrickleIceTimer(); } /// Sets a callback to be invoked when ICE negotiation is complete @@ -819,6 +875,61 @@ class Peer { _negotiationTimer = null; } + /// Starts the trickle ICE timer that sends endOfCandidates after 3 seconds of inactivity + void _startTrickleIceTimer(String callId) { + // If this is a new call or timer is not running, start it + if (_currentTrickleCallId != callId || _trickleIceTimer == null) { + _stopTrickleIceTimer(); // Clean up any existing timer + _currentTrickleCallId = callId; + _endOfCandidatesSent = false; + } + + _lastTrickleCandidateTime = DateTime.now(); + + // Start timer if not already running + _trickleIceTimer ??= Timer.periodic( + const Duration(milliseconds: 500), // Check every 500ms + (timer) { + if (_lastTrickleCandidateTime == null) return; + + final timeSinceLastCandidate = DateTime.now() + .difference(_lastTrickleCandidateTime!) + .inMilliseconds; + GlobalLogger().d( + 'Time since last trickle candidate: ${timeSinceLastCandidate}ms', + ); + + if (timeSinceLastCandidate >= _trickleIceTimeout && + !_endOfCandidatesSent) { + GlobalLogger() + .i('Trickle ICE timeout reached - sending end of candidates'); + _sendEndOfCandidatesAndCleanup(_currentTrickleCallId!); + } + }, + ); + } + + /// Stops and cleans up the trickle ICE timer + void _stopTrickleIceTimer() { + _trickleIceTimer?.cancel(); + _trickleIceTimer = null; + _lastTrickleCandidateTime = null; + _currentTrickleCallId = null; + _endOfCandidatesSent = false; + } + + /// Sends end of candidates signal and cleans up timer + void _sendEndOfCandidatesAndCleanup(String callId) { + if (!_endOfCandidatesSent) { + _sendEndOfCandidates(callId); + _endOfCandidatesSent = true; + _stopTrickleIceTimer(); + GlobalLogger().i( + 'Peer :: End of candidates sent and timer cleaned up for call $callId', + ); + } + } + /// Adds trickle ICE capability to the SDP String _addTrickleIceToSdp(String sdp) { if (!_useTrickleIce) { @@ -880,7 +991,8 @@ class Peer { final String jsonCandidateMessage = jsonEncode(candidateMessage); GlobalLogger().i( - 'Peer :: Sending trickle ICE candidate: ${candidate.candidate} (sdpMid: ${candidateParams.sdpMid}, sdpMLineIndex: ${candidateParams.sdpMLineIndex})'); + 'Peer :: Sending trickle ICE candidate: ${candidate.candidate} (sdpMid: ${candidateParams.sdpMid}, sdpMLineIndex: ${candidateParams.sdpMLineIndex})', + ); _send(jsonCandidateMessage); } catch (e) { GlobalLogger().e('Peer :: Error sending trickle ICE candidate: $e'); @@ -912,10 +1024,15 @@ class Peer { /// Handles a remote ICE candidate received via trickle ICE void handleRemoteCandidate( - String callId, String candidateStr, String sdpMid, int sdpMLineIndex) { + String callId, + String candidateStr, + String sdpMid, + int sdpMLineIndex, + ) { try { GlobalLogger().i( - 'Peer :: Handling remote candidate for call $callId: $candidateStr'); + 'Peer :: Handling remote candidate for call $callId: $candidateStr', + ); // Find the session for this call final Session? session = _sessions[_selfId]; @@ -936,15 +1053,18 @@ class Peer { }); } else { GlobalLogger().w( - 'Peer :: No session or peer connection available for call $callId'); + 'Peer :: No session or peer connection available for call $callId', + ); // Store the candidate for later if session is not ready yet final Session? pendingSession = _sessions[_selfId]; if (pendingSession != null) { - pendingSession.remoteCandidates.add(RTCIceCandidate( - candidateStr, - sdpMid, - sdpMLineIndex, - )); + pendingSession.remoteCandidates.add( + RTCIceCandidate( + candidateStr, + sdpMid, + sdpMLineIndex, + ), + ); GlobalLogger() .i('Peer :: Stored remote candidate for later processing'); } diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 9d2daeda..40455411 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -28,8 +28,13 @@ class Peer { RTCPeerConnection? peerConnection; /// The constructor for the Peer class. - Peer(this._socket, this._debug, this._txClient, this._forceRelayCandidate, - this._useTrickleIce); + Peer( + this._socket, + this._debug, + this._txClient, + this._forceRelayCandidate, + this._useTrickleIce, + ); final TxSocket _socket; final TelnyxClient _txClient; @@ -46,6 +51,14 @@ class Peer { static const int _negotiationTimeout = 500; // 500ms timeout for negotiation Function()? _onNegotiationComplete; + // Add trickle ICE end-of-candidates timer fields + Timer? _trickleIceTimer; + DateTime? _lastTrickleCandidateTime; + static const int _trickleIceTimeout = + 3000; // 3 seconds timeout for trickle ICE + String? _currentTrickleCallId; + bool _endOfCandidatesSent = false; + /// Sessions by session-id. final Map _sessions = {}; @@ -257,9 +270,14 @@ class Peer { 'Trickle ICE: Sending candidate immediately: ${candidate.candidate}', ); _sendCandidate(callId, candidate); + + // Reset the trickle ICE timer when a candidate is generated + _startTrickleIceTimer(callId); } else { - GlobalLogger().i('Trickle ICE: Sending end of candidates'); - _sendEndOfCandidates(callId); + GlobalLogger().i( + 'Trickle ICE: Sending end of candidates (natural completion)', + ); + _sendEndOfCandidatesAndCleanup(callId); } }; } @@ -271,7 +289,7 @@ class Peer { } // Send INVITE immediately for trickle ICE, or after delay for regular ICE - final sendInvite = () async { + Future sendInvite() async { final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -306,7 +324,7 @@ class Peer { final String jsonInviteMessage = jsonEncode(inviteMessage); _send(jsonInviteMessage); - }; + } if (_useTrickleIce) { // Send INVITE immediately for trickle ICE @@ -330,6 +348,25 @@ class Peer { await session.peerConnection?.setRemoteDescription( RTCSessionDescription(sdp, 'answer'), ); + + // Process any queued candidates after setting remote SDP + if (session.remoteCandidates.isNotEmpty) { + GlobalLogger().i( + 'Web Peer :: Processing queued remote candidates after remote SDP', + ); + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger().i( + 'Web Peer :: Adding queued candidate: ${candidate.candidate}', + ); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + GlobalLogger() + .i('Web Peer :: Cleared queued candidates after processing'); + } + onCallStateChange?.call(session, CallState.active); } } @@ -372,6 +409,24 @@ class Peer { RTCSessionDescription(invite.sdp, 'offer'), ); + // Process any queued candidates after setting remote SDP + if (session.remoteCandidates.isNotEmpty) { + GlobalLogger().i( + 'Web Peer :: Processing queued remote candidates after setting remote SDP in accept', + ); + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger() + .i('Web Peer :: Adding queued candidate: ${candidate.candidate}'); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + GlobalLogger().i( + 'Web Peer :: Cleared queued candidates after processing in accept', + ); + } + // Create and send the Answer (or Attach) await _createAnswer( session, @@ -415,9 +470,14 @@ class Peer { 'Trickle ICE: Sending candidate immediately: ${candidate.candidate}', ); _sendCandidate(callId, candidate); + + // Reset the trickle ICE timer when a candidate is generated + _startTrickleIceTimer(callId); } else { - GlobalLogger().i('Trickle ICE: Sending end of candidates'); - _sendEndOfCandidates(callId); + GlobalLogger().i( + 'Trickle ICE: Sending end of candidates (natural completion)', + ); + _sendEndOfCandidatesAndCleanup(callId); } }; } else { @@ -594,10 +654,13 @@ class Peer { } // Create PeerConnection - peerConnection = await createPeerConnection({ - ..._buildIceConfiguration(), - ...{'sdpSemantics': sdpSemantics}, - }, _dcConstraints); + peerConnection = await createPeerConnection( + { + ..._buildIceConfiguration(), + ...{'sdpSemantics': sdpSemantics}, + }, + _dcConstraints, + ); final pc = peerConnection!; // If we want the same plan-b/unified-plan logic as mobile: @@ -758,12 +821,12 @@ class Peer { await _localStream!.dispose(); _localStream = null; } - _sessions.forEach((key, sess) async { + _sessions..forEach((key, sess) async { await sess.peerConnection?.close(); await sess.peerConnection?.dispose(); await sess.dc?.close(); - }); - _sessions.clear(); + }) + ..clear(); _statsManager?.stopStatsReporting(); } @@ -785,6 +848,9 @@ class Peer { await session.dc?.close(); // Stop stats stopStats(session.sid); + + // Clean up trickle ICE timer when session is closed + _stopTrickleIceTimer(); } void _send(dynamic event) { @@ -826,6 +892,62 @@ class Peer { _negotiationTimer = null; } + /// Starts the trickle ICE timer that sends endOfCandidates after 3 seconds of inactivity + void _startTrickleIceTimer(String callId) { + // If this is a new call or timer is not running, start it + if (_currentTrickleCallId != callId || _trickleIceTimer == null) { + _stopTrickleIceTimer(); // Clean up any existing timer + _currentTrickleCallId = callId; + _endOfCandidatesSent = false; + } + + _lastTrickleCandidateTime = DateTime.now(); + + // Start timer if not already running + _trickleIceTimer ??= Timer.periodic( + const Duration(milliseconds: 500), // Check every 500ms + (timer) { + if (_lastTrickleCandidateTime == null) return; + + final timeSinceLastCandidate = DateTime.now() + .difference(_lastTrickleCandidateTime!) + .inMilliseconds; + GlobalLogger().d( + 'Time since last trickle candidate: ${timeSinceLastCandidate}ms', + ); + + if (timeSinceLastCandidate >= _trickleIceTimeout && + !_endOfCandidatesSent) { + GlobalLogger().i( + 'Web Peer :: Trickle ICE timeout reached - sending end of candidates', + ); + _sendEndOfCandidatesAndCleanup(_currentTrickleCallId!); + } + }, + ); + } + + /// Stops and cleans up the trickle ICE timer + void _stopTrickleIceTimer() { + _trickleIceTimer?.cancel(); + _trickleIceTimer = null; + _lastTrickleCandidateTime = null; + _currentTrickleCallId = null; + _endOfCandidatesSent = false; + } + + /// Sends end of candidates signal and cleans up timer + void _sendEndOfCandidatesAndCleanup(String callId) { + if (!_endOfCandidatesSent) { + _sendEndOfCandidates(callId); + _endOfCandidatesSent = true; + _stopTrickleIceTimer(); + GlobalLogger().i( + 'Web Peer :: End of candidates sent and timer cleaned up for call $callId', + ); + } + } + /// Adds trickle ICE support to SDP String _addTrickleIceToSdp(String sdp) { final lines = sdp.split('\r\n'); @@ -863,7 +985,8 @@ class Peer { final String jsonCandidateMessage = jsonEncode(candidateMessage); GlobalLogger().i( - 'Web Peer :: Sending trickle ICE candidate: ${candidate.candidate}'); + 'Web Peer :: Sending trickle ICE candidate: ${candidate.candidate}', + ); _send(jsonCandidateMessage); } catch (e) { GlobalLogger().e('Web Peer :: Error sending trickle ICE candidate: $e'); diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index fff66f25..43bbd18f 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -2157,8 +2157,6 @@ class TelnyxClient { if (call != null) { GlobalLogger() .i('End of candidates signaled for call: $callId'); - // The peer connection will handle this internally - // No specific action needed as WebRTC handles this automatically } else { GlobalLogger().w( 'Received endOfCandidates for unknown call: $callId'); From e0005a2b2381bbab983c08da3f86c8cc4b161981 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 22 Sep 2025 16:08:13 +0100 Subject: [PATCH 06/11] chore: format socket logs better --- packages/telnyx_webrtc/lib/telnyx_client.dart | 2 +- packages/telnyx_webrtc/lib/tx_socket.dart | 2 +- packages/telnyx_webrtc/lib/tx_socket_web.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index 43bbd18f..c9056125 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -1577,7 +1577,7 @@ class TelnyxClient { if (data != null) { if (data.toString().trim().isNotEmpty) { - GlobalLogger().i('TxSocket :: ${data.toString().trim()}'); + GlobalLogger().i('TxSocket :: Receive : ${data.toString().trim()}'); if (data.toString().trim().contains('error')) { final errorJson = jsonEncode(data.toString()); _logger.log( diff --git a/packages/telnyx_webrtc/lib/tx_socket.dart b/packages/telnyx_webrtc/lib/tx_socket.dart index 84242cc8..72243157 100644 --- a/packages/telnyx_webrtc/lib/tx_socket.dart +++ b/packages/telnyx_webrtc/lib/tx_socket.dart @@ -53,7 +53,7 @@ class TxSocket { void send(dynamic data) { if (_socket.readyState == WebSocket.open) { _socket.add(data); - GlobalLogger().i('TxSocket :: send : \n\n$data'); + GlobalLogger().i('TxSocket :: Send : ${data?.toString().trim()}'); } else { GlobalLogger().d('WebSocket not connected, message $data not sent'); } diff --git a/packages/telnyx_webrtc/lib/tx_socket_web.dart b/packages/telnyx_webrtc/lib/tx_socket_web.dart index 9871dc13..582fae92 100644 --- a/packages/telnyx_webrtc/lib/tx_socket_web.dart +++ b/packages/telnyx_webrtc/lib/tx_socket_web.dart @@ -50,7 +50,7 @@ class TxSocket { void send(data) { if (_socket.readyState == WebSocket.OPEN) { _socket.send(data); - GlobalLogger().i('TxSocket :: send : \n\n$data'); + GlobalLogger().i('TxSocket :: Send : ${data?.toString().trim()}'); } else { GlobalLogger().d('WebSocket not connected, message $data not sent'); } From 7ad4454eb4ddb2967a80219a4ba2338623f22e60 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 23 Sep 2025 10:34:17 +0100 Subject: [PATCH 07/11] feat: queue and flush candidates and use candidate utils to strip a prefix for trickle ice --- .../lib/model/pending_ice_candidate.dart | 31 +++++ packages/telnyx_webrtc/lib/telnyx_client.dart | 131 +++++++++++++++--- .../lib/utils/candidate_utils.dart | 78 +++++++++++ 3 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 packages/telnyx_webrtc/lib/model/pending_ice_candidate.dart create mode 100644 packages/telnyx_webrtc/lib/utils/candidate_utils.dart diff --git a/packages/telnyx_webrtc/lib/model/pending_ice_candidate.dart b/packages/telnyx_webrtc/lib/model/pending_ice_candidate.dart new file mode 100644 index 00000000..11f04853 --- /dev/null +++ b/packages/telnyx_webrtc/lib/model/pending_ice_candidate.dart @@ -0,0 +1,31 @@ +/// Represents a pending ICE candidate that needs to be processed +/// after the remote description is set. +class PendingIceCandidate { + /// The call ID this candidate belongs to + final String callId; + + /// The SDP media identifier + final String sdpMid; + + /// The SDP media line index + final int sdpMLineIndex; + + /// The original candidate string + final String candidateString; + + /// The enhanced candidate string (will be set later when ICE parameters are available) + final String enhancedCandidateString; + + PendingIceCandidate({ + required this.callId, + required this.sdpMid, + required this.sdpMLineIndex, + required this.candidateString, + required this.enhancedCandidateString, + }); + + @override + String toString() { + return 'PendingIceCandidate(callId: $callId, sdpMid: $sdpMid, sdpMLineIndex: $sdpMLineIndex, candidate: $candidateString)'; + } +} \ No newline at end of file diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index c9056125..4ac757bd 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -41,6 +41,8 @@ import 'package:telnyx_webrtc/model/verto/send/ringing_ack_message.dart'; import 'package:telnyx_webrtc/model/verto/send/disable_push_body.dart'; import 'package:telnyx_webrtc/model/region.dart'; import 'package:telnyx_webrtc/model/audio_codec.dart'; +import 'package:telnyx_webrtc/model/pending_ice_candidate.dart'; +import 'package:telnyx_webrtc/utils/candidate_utils.dart'; /// Callback for when the socket receives a message typedef OnSocketMessageReceived = void Function(TelnyxMessage message); @@ -170,6 +172,10 @@ class TelnyxClient { /// A map of all current calls, with the call ID as the key and the [Call] object as the value. Map calls = {}; + /// A map of pending ICE candidates, with the call ID as the key and a list of candidates as the value. + /// These candidates are queued and will be processed after the remote description is set. + final Map> pendingIceCandidates = {}; + /// The current active calls being handled by the TelnyxClient instance /// The Map key is the callId [String] and the value is the [Call] instance Map activeCalls() { @@ -530,6 +536,77 @@ class TelnyxClient { } } + /// Processes and queues the ICE candidate for the specified call. + /// + /// [callId] The ID of the call this candidate belongs to + /// [sdpMid] The SDP media identifier + /// [sdpMLineIndex] The SDP media line index + /// [candidateString] The normalized candidate string + void _processAndQueueCandidate(String callId, String sdpMid, int sdpMLineIndex, String candidateString) { + final call = calls[callId]; + if (call != null) { + // Create pending ICE candidate and queue it instead of immediately adding + // Note: We don't enhance the candidate string here because remoteIceParameters + // won't be available until after the remote description is set in onAnswerReceived + final pendingCandidate = PendingIceCandidate( + callId: callId, + sdpMid: sdpMid, + sdpMLineIndex: sdpMLineIndex, + candidateString: candidateString, + enhancedCandidateString: candidateString, // Store original for now, will enhance later + ); + + // Add to pending candidates map + final candidates = pendingIceCandidates.putIfAbsent(callId, () => []); + candidates.add(pendingCandidate); + GlobalLogger().i('Queued ICE candidate for call $callId. Total queued: ${candidates.length}'); + } else { + GlobalLogger().w('No call found for ID: $callId'); + } + } + + /// Processes any queued ICE candidates after remote description is set. + /// + /// [callId] The ID of the call whose candidates should be processed + void _processQueuedIceCandidates(String callId) { + final call = calls[callId]; + if (call == null) { + GlobalLogger().w('No call found for ID: $callId when processing queued candidates'); + return; + } + + final candidates = pendingIceCandidates[callId]; + if (candidates == null || candidates.isEmpty) { + GlobalLogger().i('No queued ICE candidates to process for call $callId'); + return; + } + + GlobalLogger().i('Processing ${candidates.length} queued ICE candidates for call $callId'); + + // Process each queued candidate + for (final candidate in candidates) { + try { + if (call.peerConnection != null) { + call.peerConnection!.handleRemoteCandidate( + candidate.callId, + candidate.enhancedCandidateString, + candidate.sdpMid, + candidate.sdpMLineIndex, + ); + GlobalLogger().i('Successfully processed queued candidate for call $callId'); + } else { + GlobalLogger().w('Peer connection is null for call $callId, cannot process candidate'); + } + } catch (e) { + GlobalLogger().e('Error processing queued candidate for call $callId: ${e.toString()}'); + } + } + + // Clear the processed candidates + pendingIceCandidates.remove(callId); + GlobalLogger().i('Cleared processed candidates for call $callId'); + } + /// Handles the timeout when no INVITE is received after accepting from push void _handlePendingAnswerTimeout() { GlobalLogger().i( @@ -1964,6 +2041,10 @@ class TelnyxClient { answerCall.onRemoteSessionReceived( inviteAnswer.inviteParams?.sdp, ); + + // Process any queued ICE candidates after remote description is set (Android-style approach) + _processQueuedIceCandidates(inviteAnswer.inviteParams!.callID!); + onSocketMessageReceived(message); } else if (_earlySDP) { onSocketMessageReceived(message); @@ -2115,30 +2196,36 @@ class TelnyxClient { final Map candidateData = jsonDecode(data.toString()); - // Extract candidate information - final String? callId = candidateData['params']?['callID']; - final String? candidateStr = - candidateData['params']?['candidate']; - final String? sdpMid = candidateData['params']?['sdpMid']; - final int? sdpMLineIndex = - candidateData['params']?['sdpMLineIndex']; + // Extract params from the candidate data + final Map? params = candidateData['params']; + if (params == null) { + GlobalLogger().w('Candidate message missing params'); + break; + } - if (callId != null && candidateStr != null) { - // Find the call and add the remote candidate - final Call? call = calls[callId]; - if (call != null && call.peerConnection != null) { - // Add remote candidate to peer connection - call.peerConnection!.handleRemoteCandidate( - callId, - candidateStr, - sdpMid ?? '0', - sdpMLineIndex ?? 0, - ); - } else { - GlobalLogger() - .w('Received candidate for unknown call: $callId'); - } + // Validate required fields + if (!CandidateUtils.hasRequiredCandidateFields(params)) { + GlobalLogger().w('Candidate message missing required fields (candidate, sdpMid, or sdpMLineIndex)'); + break; } + + // Extract call ID using the utility method + final String? callId = CandidateUtils.extractCallIdFromCandidate(params); + if (callId == null) { + GlobalLogger().w('Could not extract call ID from candidate message'); + break; + } + + // Normalize the candidate string to handle "a=" prefix issue + final String candidateStr = params['candidate'] as String; + final String normalizedCandidate = CandidateUtils.normalizeCandidateString(candidateStr); + + // Extract other required fields + final String sdpMid = params['sdpMid'] as String; + final int sdpMLineIndex = params['sdpMLineIndex'] as int; + + // Process and queue the candidate (Android-style approach) + _processAndQueueCandidate(callId, sdpMid, sdpMLineIndex, normalizedCandidate); break; } case SocketMethod.endOfCandidates: diff --git a/packages/telnyx_webrtc/lib/utils/candidate_utils.dart b/packages/telnyx_webrtc/lib/utils/candidate_utils.dart new file mode 100644 index 00000000..b7d43236 --- /dev/null +++ b/packages/telnyx_webrtc/lib/utils/candidate_utils.dart @@ -0,0 +1,78 @@ +import 'package:telnyx_webrtc/utils/logging/global_logger.dart'; +import 'package:uuid/uuid.dart'; + +/// Utility class for handling ICE candidate processing and validation. +/// This class contains helper methods extracted from TelnyxClient to reduce complexity +/// and improve code organization. +class CandidateUtils { + /// Validates that the candidate message has all required fields. + /// + /// [params] The JSON object containing candidate parameters + /// Returns true if all required fields are present, false otherwise + static bool hasRequiredCandidateFields(Map params) { + return params.containsKey('candidate') && + params.containsKey('sdpMid') && + params.containsKey('sdpMLineIndex'); + } + + /// Normalizes the candidate string by ensuring proper prefix. + /// Handles different formats that might be received from the server. + /// + /// [candidateString] The original candidate string + /// Returns the normalized candidate string with proper "candidate:" prefix + static String normalizeCandidateString(String candidateString) { + if (candidateString.startsWith('a=candidate:')) { + // Only strip "a=" not "a=candidate:" + final normalized = candidateString.substring(2); // Remove "a=" + GlobalLogger().i('Stripped \'a=\' prefix from candidate string'); + return normalized; + } else if (!candidateString.startsWith('candidate:')) { + // If it doesn't start with "candidate:", add it + final normalized = 'candidate:$candidateString'; + GlobalLogger().i('Added \'candidate:\' prefix to candidate string'); + return normalized; + } else { + return candidateString; + } + } + + /// Extracts call ID from the candidate message parameters. + /// Supports both new server format (callID in params) and legacy format (callId in dialogParams). + /// + /// [params] The JSON object containing candidate parameters + /// Returns the extracted UUID if found, null otherwise + static String? extractCallIdFromCandidate(Map params) { + String? callId; + + // Try to get call ID from multiple possible locations + + // 1. Check directly in params for "callID" (new server format) + if (params.containsKey('callID')) { + try { + callId = params['callID'] as String?; + if (callId != null) { + GlobalLogger().i('Found callID directly in params: $callId'); + } + } catch (e) { + GlobalLogger().e('Failed to parse callID from params: ${e.toString()}'); + } + } + + // 2. Fallback to dialogParams for "callId" (legacy format) if not found yet + if (callId == null && params.containsKey('dialogParams')) { + final dialogParams = params['dialogParams'] as Map?; + if (dialogParams != null && dialogParams.containsKey('callId')) { + try { + callId = dialogParams['callId'] as String?; + if (callId != null) { + GlobalLogger().i('Found callId in dialogParams: $callId'); + } + } catch (e) { + GlobalLogger().e('Failed to parse callId from dialogParams: ${e.toString()}'); + } + } + } + + return callId; + } +} \ No newline at end of file From 479e744cf8423679c6d9ae1a4b8dd07151317d9c Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 23 Sep 2025 13:28:39 +0100 Subject: [PATCH 08/11] feat: don't include candidates in initial invite for trickle ice, and move sdp handling methods to SDP Utils class --- packages/telnyx_webrtc/lib/peer/peer.dart | 119 ++++++-------- packages/telnyx_webrtc/lib/peer/web/peer.dart | 150 ++++++++++-------- .../telnyx_webrtc/lib/utils/sdp_utils.dart | 119 ++++++++++++++ 3 files changed, 253 insertions(+), 135 deletions(-) create mode 100644 packages/telnyx_webrtc/lib/utils/sdp_utils.dart diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index 5b6de68b..fcd1f8a2 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -17,6 +17,7 @@ import 'package:telnyx_webrtc/utils/version_utils.dart'; import 'package:telnyx_webrtc/tx_socket.dart' if (dart.library.js) 'package:telnyx_webrtc/tx_socket_web.dart'; import 'package:telnyx_webrtc/utils/string_utils.dart'; +import 'package:telnyx_webrtc/utils/sdp_utils.dart'; import 'package:uuid/uuid.dart'; import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; import 'package:telnyx_webrtc/model/call_state.dart'; @@ -221,30 +222,21 @@ class Peer { List>? preferredCodecs, ) async { try { - final RTCSessionDescription s = await session.peerConnection!.createOffer( - _dcConstraints, - ); - await session.peerConnection!.setLocalDescription(s); - - if (session.remoteCandidates.isNotEmpty) { - for (var candidate in session.remoteCandidates) { - if (candidate.candidate != null) { - GlobalLogger().i('adding $candidate'); - await session.peerConnection?.addCandidate(candidate); - } - } - session.remoteCandidates.clear(); - } - - // With trickle ICE, send offer immediately without waiting for candidates + // With trickle ICE, create offer without waiting for ICE gathering if (_useTrickleIce) { - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); - + // Create offer with proper constraints but don't wait for ICE candidate gathering + final RTCSessionDescription s = await session.peerConnection!.createOffer( + _dcConstraints, + ); + + // For trickle ICE, we set the local description but don't wait for candidates + await session.peerConnection!.setLocalDescription(s); + + // Get the SDP immediately - it should not contain candidates yet + String? sdpUsed = s.sdp; + // Add trickle ICE capability to SDP - sdpUsed = _addTrickleIceToSdp(sdpUsed ?? ''); + sdpUsed = SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( @@ -279,10 +271,25 @@ class Peer { final String jsonInviteMessage = jsonEncode(inviteMessage); GlobalLogger() - .i('Peer :: Sending INVITE with trickle ICE enabled (immediate)'); + .i('Peer :: Sending INVITE with trickle ICE enabled (no candidate gathering)'); _send(jsonInviteMessage); } else { // Traditional ICE gathering - wait for candidates + final RTCSessionDescription s = await session.peerConnection!.createOffer( + _dcConstraints, + ); + await session.peerConnection!.setLocalDescription(s); + + if (session.remoteCandidates.isNotEmpty) { + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger().i('adding $candidate'); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + } + await Future.delayed(const Duration(milliseconds: 500)); String? sdpUsed = ''; @@ -476,19 +483,19 @@ class Peer { } }; - final RTCSessionDescription s = - await session.peerConnection!.createAnswer(_dcConstraints); - await session.peerConnection!.setLocalDescription(s); - if (_useTrickleIce) { - // With trickle ICE, send answer immediately without waiting for candidates - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); - + // With trickle ICE, create answer without waiting for ICE gathering + final RTCSessionDescription s = + await session.peerConnection!.createAnswer(_dcConstraints); + + // For trickle ICE, we set the local description but don't wait for candidates + await session.peerConnection!.setLocalDescription(s); + + // Get the SDP immediately - it should not contain candidates yet + String? sdpUsed = s.sdp; + // Add trickle ICE capability to SDP - sdpUsed = _addTrickleIceToSdp(sdpUsed ?? ''); + sdpUsed = SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( @@ -527,6 +534,10 @@ class Peer { _send(jsonAnswerMessage); } else { // Traditional ICE gathering - wait for candidates + final RTCSessionDescription s = + await session.peerConnection!.createAnswer(_dcConstraints); + await session.peerConnection!.setLocalDescription(s); + _lastCandidateTime = DateTime.now(); _setOnNegotiationComplete(() async { String? sdpUsed = ''; @@ -930,45 +941,7 @@ class Peer { } } - /// Adds trickle ICE capability to the SDP - String _addTrickleIceToSdp(String sdp) { - if (!_useTrickleIce) { - return sdp; - } - - // Check if ice-options:trickle already exists - if (sdp.contains('a=ice-options:trickle')) { - return sdp; - } - - // Find the first media line (m=) and add ice-options after it - final lines = sdp.split('\r\n'); - final modifiedLines = []; - bool addedTrickleIce = false; - - for (int i = 0; i < lines.length; i++) { - modifiedLines.add(lines[i]); - - // Add ice-options:trickle after the first m= line - if (!addedTrickleIce && lines[i].startsWith('m=')) { - // Look for the next line that starts with 'a=' and add before it - // or add immediately after the m= line - int insertIndex = i + 1; - while ( - insertIndex < lines.length && lines[insertIndex].startsWith('c=')) { - modifiedLines.add(lines[insertIndex]); - insertIndex++; - i++; - } - modifiedLines.add('a=ice-options:trickle'); - addedTrickleIce = true; - } - } - - final modifiedSdp = modifiedLines.join('\r\n'); - GlobalLogger().i('Peer :: Added trickle ICE to SDP'); - return modifiedSdp; - } + /// Sends a trickle ICE candidate to the remote peer void _sendTrickleCandidate(RTCIceCandidate candidate, String callId) { diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 40455411..7c0f074c 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -18,6 +18,7 @@ import 'package:telnyx_webrtc/tx_socket.dart' if (dart.library.js) 'package:telnyx_webrtc/tx_socket_web.dart'; import 'package:telnyx_webrtc/utils/logging/global_logger.dart'; import 'package:telnyx_webrtc/utils/string_utils.dart'; +import 'package:telnyx_webrtc/utils/sdp_utils.dart'; import 'package:telnyx_webrtc/utils/stats/webrtc_stats_reporter.dart'; import 'package:telnyx_webrtc/utils/version_utils.dart'; import 'package:uuid/uuid.dart'; @@ -238,31 +239,7 @@ class Peer { bool useTrickleIce, ) async { try { - final description = await session.peerConnection!.createOffer( - _dcConstraints, - ); - await session.peerConnection!.setLocalDescription(description); - - // For trickle ICE, modify SDP to include trickle support - if (_useTrickleIce) { - final modifiedSdp = _addTrickleIceToSdp(description.sdp!); - final modifiedDescription = - RTCSessionDescription(modifiedSdp, description.type!); - await session.peerConnection!.setLocalDescription(modifiedDescription); - } - - // Add any remote candidates that arrived early - if (session.remoteCandidates.isNotEmpty) { - for (var candidate in session.remoteCandidates) { - if (candidate.candidate != null) { - GlobalLogger().i('adding remote candidate: $candidate'); - await session.peerConnection?.addCandidate(candidate); - } - } - session.remoteCandidates.clear(); - } - - // For trickle ICE, set up immediate candidate sending + // For trickle ICE, set up immediate candidate sending first if (_useTrickleIce) { session.peerConnection?.onIceCandidate = (candidate) async { if (candidate.candidate != null) { @@ -282,10 +259,46 @@ class Peer { }; } + // Add any remote candidates that arrived early + if (session.remoteCandidates.isNotEmpty) { + for (var candidate in session.remoteCandidates) { + if (candidate.candidate != null) { + GlobalLogger().i('adding remote candidate: $candidate'); + await session.peerConnection?.addCandidate(candidate); + } + } + session.remoteCandidates.clear(); + } + String? sdpUsed = ''; - final localDesc = await session.peerConnection?.getLocalDescription(); - if (localDesc != null) { - sdpUsed = localDesc.sdp; + + if (_useTrickleIce) { + // For trickle ICE, create offer without waiting for ICE gathering + final description = await session.peerConnection!.createOffer( + _dcConstraints, + ); + + // For trickle ICE, add trickle support to SDP before setting local description + final modifiedSdp = SdpUtils.addTrickleIceCapability(description.sdp!, _useTrickleIce); + final modifiedDescription = + RTCSessionDescription(modifiedSdp, description.type!); + + // Set local description but don't wait for candidates + await session.peerConnection!.setLocalDescription(modifiedDescription); + + // Get the SDP immediately from the original description (before candidate gathering) + sdpUsed = modifiedSdp; + } else { + // Traditional ICE gathering + final description = await session.peerConnection!.createOffer( + _dcConstraints, + ); + await session.peerConnection!.setLocalDescription(description); + + final localDesc = await session.peerConnection?.getLocalDescription(); + if (localDesc != null) { + sdpUsed = localDesc.sdp; + } } // Send INVITE immediately for trickle ICE, or after delay for regular ICE @@ -510,22 +523,21 @@ class Peer { }; } - // Create and set local description - final description = await session.peerConnection!.createAnswer( - _dcConstraints, - ); - await session.peerConnection!.setLocalDescription(description); - - // For trickle ICE, modify SDP to include trickle support + // Handle answer creation based on trickle ICE mode if (_useTrickleIce) { - final modifiedSdp = _addTrickleIceToSdp(description.sdp!); + // For trickle ICE, create answer without waiting for ICE gathering + final description = await session.peerConnection!.createAnswer( + _dcConstraints, + ); + + // For trickle ICE, add trickle support to SDP before setting local description + final modifiedSdp = SdpUtils.addTrickleIceCapability(description.sdp!, _useTrickleIce); final modifiedDescription = RTCSessionDescription(modifiedSdp, description.type!); + + // Set local description but don't wait for candidates await session.peerConnection!.setLocalDescription(modifiedDescription); - } - - // Handle answer sending based on trickle ICE mode - if (_useTrickleIce) { + // For trickle ICE, send answer immediately await _sendAnswerMessage( session, @@ -536,7 +548,30 @@ class Peer { customHeaders, isAttach, preferredCodecs, + modifiedSdp, // Pass the SDP directly to avoid getting it later ); + } else { + // Traditional ICE gathering + final description = await session.peerConnection!.createAnswer( + _dcConstraints, + ); + await session.peerConnection!.setLocalDescription(description); + + // For regular ICE, start candidate gathering and wait for negotiation to complete + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + await _sendAnswerMessage( + session, + callId, + callerNumber, + destinationNumber, + clientState, + customHeaders, + isAttach, + preferredCodecs, + ); + }); + } } else { // For regular ICE, start candidate gathering and wait for negotiation to complete _lastCandidateTime = DateTime.now(); @@ -567,12 +602,19 @@ class Peer { String clientState, Map customHeaders, bool isAttach, - List>? preferredCodecs, - ) async { + List>? preferredCodecs, [ + String? preGeneratedSdp, + ]) async { String? sdpUsed = ''; - final localDesc = await session.peerConnection?.getLocalDescription(); - if (localDesc != null) { - sdpUsed = localDesc.sdp; + + // Use pre-generated SDP for trickle ICE, otherwise get from peer connection + if (preGeneratedSdp != null) { + sdpUsed = preGeneratedSdp; + } else { + final localDesc = await session.peerConnection?.getLocalDescription(); + if (localDesc != null) { + sdpUsed = localDesc.sdp; + } } final userAgent = await VersionUtils.getUserAgent(); @@ -948,23 +990,7 @@ class Peer { } } - /// Adds trickle ICE support to SDP - String _addTrickleIceToSdp(String sdp) { - final lines = sdp.split('\r\n'); - final modifiedLines = []; - bool trickleAdded = false; - - for (final line in lines) { - modifiedLines.add(line); - // Add a=ice-options:trickle after the first m= line - if (line.startsWith('m=') && !trickleAdded) { - modifiedLines.add('a=ice-options:trickle'); - trickleAdded = true; - } - } - - return modifiedLines.join('\r\n'); - } + /// Sends an ICE candidate via WebSocket void _sendCandidate(String callId, RTCIceCandidate candidate) { diff --git a/packages/telnyx_webrtc/lib/utils/sdp_utils.dart b/packages/telnyx_webrtc/lib/utils/sdp_utils.dart new file mode 100644 index 00000000..41a1057c --- /dev/null +++ b/packages/telnyx_webrtc/lib/utils/sdp_utils.dart @@ -0,0 +1,119 @@ +import 'package:telnyx_webrtc/utils/logging/global_logger.dart'; + +/// Utility class for Session Description Protocol (SDP) manipulation. +class SdpUtils { + + /// Adds trickle ICE capability to an SDP if not already present. + /// This adds "a=ice-options:trickle" at the session level after the origin (o=) line. + /// + /// [sdp] The original SDP string + /// [useTrickleIce] Whether trickle ICE is enabled + /// @return The modified SDP with ice-options:trickle added, or original if no modification needed + static String addTrickleIceCapability(String sdp, bool useTrickleIce) { + if (!useTrickleIce) { + return sdp; + } + + final lines = sdp.split('\r\n').toList(); + var result = _handleTrickleIceModification(lines); + + if (result != null) { + GlobalLogger().i('SdpUtils :: Modified SDP with trickle ICE capability'); + return result; + } else { + GlobalLogger().i('SdpUtils :: SDP already contains trickle ICE or no modification needed'); + return sdp; + } + } + + /// Handles trickle ICE modification by checking existing ice-options and adding if needed + static String? _handleTrickleIceModification(List lines) { + // Check if there's an existing ice-options line that needs modification + final existingIceOptionsIndex = _findExistingIceOptionsIndex(lines); + if (existingIceOptionsIndex != -1) { + return _handleExistingIceOptions(lines, existingIceOptionsIndex); + } + + // If no existing ice-options line was found, try to add a new one + return _addNewIceOptions(lines); + } + + /// Finds the index of an existing ice-options line + static int _findExistingIceOptionsIndex(List lines) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].startsWith('a=ice-options:')) { + return i; + } + } + return -1; + } + + /// Handles an existing ice-options line + static String? _handleExistingIceOptions(List lines, int index) { + final currentOptions = lines[index]; + + if (currentOptions == 'a=ice-options:trickle') { + // Already has exactly what we want + return null; + } else { + // Replace any ice-options line with just trickle + // This handles cases like "a=ice-options:trickle renomination" + lines[index] = 'a=ice-options:trickle'; + GlobalLogger().i('SdpUtils :: Replaced ice-options line from \'$currentOptions\' to \'a=ice-options:trickle\''); + return lines.join('\r\n'); + } + } + + /// Adds a new ice-options line to the SDP + static String? _addNewIceOptions(List lines) { + final insertIndex = _findOriginLineInsertIndex(lines); + + if (insertIndex != -1) { + // Insert ice-options:trickle at session level (after origin line) + lines.insert(insertIndex, 'a=ice-options:trickle'); + GlobalLogger().i('SdpUtils :: Added a=ice-options:trickle to SDP at index $insertIndex'); + return lines.join('\r\n'); + } else { + GlobalLogger().w('SdpUtils :: Could not find origin line in SDP, returning original'); + return null; + } + } + + /// Finds the index where the ice-options line should be inserted (after origin line) + static int _findOriginLineInsertIndex(List lines) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].startsWith('o=')) { + return i + 1; + } + } + return -1; + } + + /// Checks if an SDP contains trickle ICE capability. + /// + /// [sdp] The SDP string to check + /// @return true if the SDP advertises trickle ICE support + static bool hasTrickleIceCapability(String sdp) { + return sdp.contains("a=ice-options:trickle"); + } + + /// Removes ICE candidates from SDP for trickle ICE + /// + /// [sdp] The SDP string to process + /// @return The SDP with ICE candidates removed + static String removeIceCandidatesFromSdp(String sdp) { + final lines = sdp.split('\r\n'); + final modifiedLines = []; + + for (final line in lines) { + // Remove candidate lines (a=candidate:) + if (!line.startsWith('a=candidate:')) { + modifiedLines.add(line); + } + } + + final modifiedSdp = modifiedLines.join('\r\n'); + GlobalLogger().i('SdpUtils :: Removed ICE candidates from SDP for trickle ICE'); + return modifiedSdp; + } +} \ No newline at end of file From da23c9c029a498292e5c1f64f09172d3faaaf57a Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 23 Sep 2025 13:49:17 +0100 Subject: [PATCH 09/11] fix: remove extra else block from web peer --- packages/telnyx_webrtc/lib/peer/web/peer.dart | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 7c0f074c..7312f28f 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -572,22 +572,6 @@ class Peer { ); }); } - } else { - // For regular ICE, start candidate gathering and wait for negotiation to complete - _lastCandidateTime = DateTime.now(); - _setOnNegotiationComplete(() async { - await _sendAnswerMessage( - session, - callId, - callerNumber, - destinationNumber, - clientState, - customHeaders, - isAttach, - preferredCodecs, - ); - }); - } } catch (e) { GlobalLogger().e('Peer :: _createAnswer error: $e'); } From 26bc77b990ae74ed7ec5567588dfbbab001d80d9 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 23 Sep 2025 16:04:33 +0100 Subject: [PATCH 10/11] fix: lazy initialise audioService and lower timeout to 300ms on traditional ice gathering --- packages/telnyx_webrtc/lib/call.dart | 5 +- packages/telnyx_webrtc/lib/peer/peer.dart | 49 ++++++++++--------- packages/telnyx_webrtc/lib/peer/web/peer.dart | 8 +-- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/telnyx_webrtc/lib/call.dart b/packages/telnyx_webrtc/lib/call.dart index 87f10d17..62c7f0a5 100644 --- a/packages/telnyx_webrtc/lib/call.dart +++ b/packages/telnyx_webrtc/lib/call.dart @@ -124,8 +124,9 @@ class Call { /// - Represents states like: newCall, ringing, connecting, active, held, done, etc. late CallState callState; - /// AudioService instance to handle audio playback - final audioService = AudioService(); + /// AudioService instance to handle audio playback (lazy initialized) + AudioService get audioService => _audioService ??= AudioService(); + AudioService? _audioService; /// Debug mode flag to enable call quality metrics final bool debug; diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index fcd1f8a2..dcef0308 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -49,7 +49,7 @@ class Peer { // Add negotiation timer fields Timer? _negotiationTimer; DateTime? _lastCandidateTime; - static const int _negotiationTimeout = 500; // 500ms timeout for negotiation + static const int _negotiationTimeout = 300; // 300ms timeout for negotiation Function()? _onNegotiationComplete; // Add trickle ICE end-of-candidates timer fields @@ -225,18 +225,20 @@ class Peer { // With trickle ICE, create offer without waiting for ICE gathering if (_useTrickleIce) { // Create offer with proper constraints but don't wait for ICE candidate gathering - final RTCSessionDescription s = await session.peerConnection!.createOffer( + final RTCSessionDescription s = + await session.peerConnection!.createOffer( _dcConstraints, ); - + // For trickle ICE, we set the local description but don't wait for candidates await session.peerConnection!.setLocalDescription(s); - + // Get the SDP immediately - it should not contain candidates yet String? sdpUsed = s.sdp; - + // Add trickle ICE capability to SDP - sdpUsed = SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); + sdpUsed = + SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( @@ -270,12 +272,14 @@ class Peer { ); final String jsonInviteMessage = jsonEncode(inviteMessage); - GlobalLogger() - .i('Peer :: Sending INVITE with trickle ICE enabled (no candidate gathering)'); + GlobalLogger().i( + 'Peer :: Sending INVITE with trickle ICE enabled (no candidate gathering)', + ); _send(jsonInviteMessage); } else { - // Traditional ICE gathering - wait for candidates - final RTCSessionDescription s = await session.peerConnection!.createOffer( + // Traditional ICE gathering - use negotiation timer + final RTCSessionDescription s = + await session.peerConnection!.createOffer( _dcConstraints, ); await session.peerConnection!.setLocalDescription(s); @@ -290,14 +294,13 @@ class Peer { session.remoteCandidates.clear(); } - await Future.delayed(const Duration(milliseconds: 500)); - - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); - Timer(const Duration(milliseconds: 500), () async { final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -330,7 +333,6 @@ class Peer { ); final String jsonInviteMessage = jsonEncode(inviteMessage); - _send(jsonInviteMessage); }); } @@ -487,15 +489,16 @@ class Peer { // With trickle ICE, create answer without waiting for ICE gathering final RTCSessionDescription s = await session.peerConnection!.createAnswer(_dcConstraints); - + // For trickle ICE, we set the local description but don't wait for candidates await session.peerConnection!.setLocalDescription(s); - + // Get the SDP immediately - it should not contain candidates yet String? sdpUsed = s.sdp; - + // Add trickle ICE capability to SDP - sdpUsed = SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); + sdpUsed = + SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( @@ -941,8 +944,6 @@ class Peer { } } - - /// Sends a trickle ICE candidate to the remote peer void _sendTrickleCandidate(RTCIceCandidate candidate, String callId) { try { diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 7312f28f..7d3e6365 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -49,7 +49,7 @@ class Peer { /// Add negotiation timer fields Timer? _negotiationTimer; DateTime? _lastCandidateTime; - static const int _negotiationTimeout = 500; // 500ms timeout for negotiation + static const int _negotiationTimeout = 300; // 300ms timeout for negotiation Function()? _onNegotiationComplete; // Add trickle ICE end-of-candidates timer fields @@ -343,9 +343,9 @@ class Peer { // Send INVITE immediately for trickle ICE await sendInvite(); } else { - // Wait for ICE gathering to complete for regular ICE - await Future.delayed(const Duration(milliseconds: 500)); - Timer(const Duration(milliseconds: 500), sendInvite); + // Traditional ICE gathering - use negotiation timer + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(sendInvite); } } catch (e) { GlobalLogger().e('Peer :: _createOffer error: $e'); From 87aaa96a2291e835ad96dac5df1197e7659c909a Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 28 Oct 2025 13:59:27 +0000 Subject: [PATCH 11/11] fix: re-remove preferred codecs from answer in web peer --- packages/telnyx_webrtc/lib/peer/peer.dart | 7 +++-- packages/telnyx_webrtc/lib/peer/web/peer.dart | 26 +++++++------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/telnyx_webrtc/lib/peer/peer.dart b/packages/telnyx_webrtc/lib/peer/peer.dart index 5e6dfee4..35d71092 100644 --- a/packages/telnyx_webrtc/lib/peer/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/peer.dart @@ -279,7 +279,7 @@ class Peer { sdpUsed = SdpUtils.addTrickleIceCapability(sdpUsed ?? '', _useTrickleIce); - final userAgent = await VersionUtils.getUserAgent(); + final userAgent = VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, audio: true, @@ -340,7 +340,7 @@ class Peer { (value) => sdpUsed = value?.sdp.toString(), ); - final userAgent = await VersionUtils.getUserAgent(); + final userAgent = VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, audio: true, @@ -583,7 +583,7 @@ class Peer { (value) => sdpUsed = value?.sdp.toString(), ); - final userAgent = await VersionUtils.getUserAgent(); + final userAgent = VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, audio: true, @@ -598,7 +598,6 @@ class Peer { userVariables: [], video: false, customHeaders: customHeaders, - preferredCodecs: preferredCodecs, ); final inviteParams = InviteParams( dialogParams: dialogParams, diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 59d2bcec..ec84bb48 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -413,18 +413,15 @@ class Peer { /// [invite] The incoming invite parameters containing the SDP offer. /// [customHeaders] Custom headers to include in the answer. /// [isAttach] Whether this is an attach call. - /// [preferredCodecs] Optional list of preferred audio codecs. Future accept( - String callerName, - String callerNumber, - String destinationNumber, - String clientState, - String callId, - IncomingInviteParams invite, - Map customHeaders, - bool isAttach, { - List>? preferredCodecs, - }) async { + String callerName, + String callerNumber, + String destinationNumber, + String clientState, + String callId, + IncomingInviteParams invite, + Map customHeaders, + bool isAttach) async { final sessionId = _selfId; final session = await _createSession( null, @@ -470,7 +467,6 @@ class Peer { callId, customHeaders, isAttach, - preferredCodecs, ); onCallStateChange?.call(session, CallState.active); @@ -486,7 +482,6 @@ class Peer { String callId, Map customHeaders, bool isAttach, - List>? preferredCodecs, ) async { try { // ICE candidate callback @@ -566,7 +561,6 @@ class Peer { clientState, customHeaders, isAttach, - preferredCodecs, modifiedSdp, // Pass the SDP directly to avoid getting it later ); } else { @@ -587,7 +581,6 @@ class Peer { clientState, customHeaders, isAttach, - preferredCodecs, ); }); } @@ -604,8 +597,7 @@ class Peer { String destinationNumber, String clientState, Map customHeaders, - bool isAttach, - List>? preferredCodecs, [ + bool isAttach, [ String? preGeneratedSdp, ]) async { String? sdpUsed = '';