diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index 40053e5c..38329b78 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -117,11 +117,18 @@ 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; case 'Force ICE Renegotiation': - Provider.of(context, listen: false).forceIceRenegotiation(); + Provider + .of(context, listen: false) + .forceIceRenegotiation(); break; } } @@ -138,33 +145,37 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { final clientState = context.select( - (txClient) => txClient.callState, + (txClient) => txClient.callState, ); final profileProvider = context.watch(); final selectedProfile = profileProvider.selectedProfile; + final clientViewModel = context.watch(); + final useTrickleIce = clientViewModel.useTrickleIce; + final errorMessage = context.select( - (viewModel) => viewModel.errorDialogMessage, + (viewModel) => viewModel.errorDialogMessage, ); if (errorMessage != null) { WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( context: context, - builder: (_) => AlertDialog( - title: const Text('Error'), - content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () { - context.read().clearErrorDialog(); - Navigator.of(context).pop(); - }, - child: const Text('OK'), + builder: (_) => + AlertDialog( + title: const Text('Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + context.read().clearErrorDialog(); + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], ), - ], - ), ); }); } @@ -177,56 +188,64 @@ 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', - 'Force ICE Renegotiation' - }.map(( - String choice, - ) { - return PopupMenuItem( - value: choice, - child: Text(choice), - ); - }).toList(); - }, - ) - else if (clientState == CallStateStatus.disconnected && - selectedProfile != null) - PopupMenuButton( - onSelected: handleOptionClick, - itemBuilder: (BuildContext context) { - final debugToggleText = selectedProfile.isDebug - ? 'Disable Debugging' - : 'Enable Debugging'; - return {'Export Logs', debugToggleText, 'Assistant Login', 'Force ICE Renegotiation'} - .map((String choice) { - return PopupMenuItem( - value: choice, - child: Text(choice), - ); - }).toList(); - }, - ) - else if (clientState == CallStateStatus.ongoingCall || - clientState == CallStateStatus.ringing || - clientState == CallStateStatus.ongoingInvitation || - clientState == CallStateStatus.connectingToCall) - PopupMenuButton( - onSelected: handleOptionClick, - itemBuilder: (BuildContext context) { - return { - 'Force ICE Renegotiation', + 'Audio Codecs', 'Export Logs', - }.map((String choice) { + 'Disable Push Notifications', + 'Force ICE Renegotiation' + }.map((String choice,) { return PopupMenuItem( value: choice, child: Text(choice), ); }).toList(); }, - ), + ) + else + if (clientState == CallStateStatus.disconnected && + selectedProfile != null) + PopupMenuButton( + onSelected: handleOptionClick, + itemBuilder: (BuildContext context) { + final debugToggleText = selectedProfile.isDebug + ? 'Disable Debugging' + : 'Enable Debugging'; + return { + 'Export Logs', + debugToggleText, + 'Assistant Login', + 'Force ICE Renegotiation' + } + .map((String choice) { + return PopupMenuItem( + value: choice, + child: Text(choice), + ); + }).toList(); + }, + ) + else + if (clientState == CallStateStatus.ongoingCall || + clientState == CallStateStatus.ringing || + clientState == CallStateStatus.ongoingInvitation || + clientState == CallStateStatus.connectingToCall) + PopupMenuButton( + onSelected: handleOptionClick, + itemBuilder: (BuildContext context) { + return { + 'Force ICE Renegotiation', + 'Export Logs', + }.map((String choice) { + return PopupMenuItem( + value: choice, + child: Text(choice), + ); + }).toList(); + }, + ), ], ), body: SingleChildScrollView( @@ -249,41 +268,42 @@ class _HomeScreenState extends State { ), bottomNavigationBar: clientState == CallStateStatus.idle ? Padding( - padding: const EdgeInsets.all(spacingXXL), - child: BottomConnectionActionWidget( - buttonTitle: 'Disconnect', - onPressed: () => { - context.read().disconnect(), - }, - ), - ) + padding: const EdgeInsets.all(spacingXXL), + child: BottomConnectionActionWidget( + buttonTitle: 'Disconnect', + onPressed: () => + { + context.read().disconnect(), + }, + ), + ) : clientState == CallStateStatus.disconnected - ? // Connect Bottom Action widget positioned at the bottom - Consumer( - builder: (context, viewModel, child) { - final profileProvider = context.watch(); - final selectedProfile = profileProvider.selectedProfile; - return Padding( - padding: const EdgeInsets.all(spacingXXL), - child: BottomConnectionActionWidget( - buttonTitle: 'Connect', - isLoading: viewModel.loggingIn, - onPressed: selectedProfile != null - ? () async { - final config = - await selectedProfile.toTelnyxConfig(); - if (config is TokenConfig) { - viewModel.loginWithToken(config); - } else if (config is CredentialConfig) { - viewModel.login(config); - } - } - : null, - ), - ); - }, - ) - : null, + ? // Connect Bottom Action widget positioned at the bottom + Consumer( + builder: (context, viewModel, child) { + final profileProvider = context.watch(); + final selectedProfile = profileProvider.selectedProfile; + return Padding( + padding: const EdgeInsets.all(spacingXXL), + child: BottomConnectionActionWidget( + buttonTitle: 'Connect', + isLoading: viewModel.loggingIn, + onPressed: selectedProfile != null + ? () async { + final config = + await selectedProfile.toTelnyxConfig(); + if (config is TokenConfig) { + viewModel.loginWithToken(config); + } else if (config is CredentialConfig) { + viewModel.login(config); + } + } + : null, + ), + ); + }, + ) + : null, ); } } diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index e42d97a8..59de032f 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -53,6 +53,7 @@ class TelnyxClientViewModel with ChangeNotifier { bool _mute = false; bool _hold = false; bool _isAssistantMode = false; + bool _useTrickleIce = false; List _supportedCodecs = []; List _preferredCodecs = []; @@ -128,6 +129,13 @@ class TelnyxClientViewModel with ChangeNotifier { return _isAssistantMode; } + bool get useTrickleIce => _useTrickleIce; + + void toggleTrickleIce() { + _useTrickleIce = !_useTrickleIce; + notifyListeners(); + } + List get supportedCodecs => _supportedCodecs; List get preferredCodecs => _preferredCodecs; @@ -388,6 +396,7 @@ class TelnyxClientViewModel with ChangeNotifier { if (config.notificationToken != null) { await prefs.setString('notificationToken', config.notificationToken!); } + await prefs.setBool('forceRelayCandidate', config.forceRelayCandidate); } Future _clearConfigForAutoLogin() async { @@ -398,6 +407,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() { @@ -791,6 +802,7 @@ class TelnyxClientViewModel with ChangeNotifier { customHeaders: {'X-Header-1': 'Value1', 'X-Header-2': 'Value2'}, preferredCodecs: _preferredCodecs.isNotEmpty ? _preferredCodecs : null, debug: true, + useTrickleIce: _useTrickleIce, ); logger.i( @@ -907,6 +919,7 @@ class TelnyxClientViewModel with ChangeNotifier { 'State', customHeaders: {}, debug: true, + useTrickleIce: _useTrickleIce, ); observeCurrentCall(); diff --git a/packages/telnyx_webrtc/lib/call.dart b/packages/telnyx_webrtc/lib/call.dart index 1593ccc5..4d400342 100644 --- a/packages/telnyx_webrtc/lib/call.dart +++ b/packages/telnyx_webrtc/lib/call.dart @@ -125,8 +125,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; @@ -249,6 +250,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, @@ -257,6 +259,7 @@ class Call { bool isAttach = false, Map customHeaders = const {}, bool debug = false, + bool useTrickleIce = false, }) { // Store the session information for later use sessionCallerName = callerName; @@ -276,6 +279,7 @@ class Call { customHeaders: customHeaders, isAttach: isAttach, debug: debug, + useTrickleIce: useTrickleIce, ); } 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/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/model/verto/send/invite_answer_message_body.dart b/packages/telnyx_webrtc/lib/model/verto/send/invite_answer_message_body.dart index 07662b2d..5c79ba79 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 @@ -32,8 +32,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 @@ -42,6 +44,7 @@ class InviteParams { sdp = json['sdp']; sessid = json['sessid']; userAgent = json['User-Agent']; + trickle = json['trickle']; } Map toJson() { @@ -52,6 +55,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 4e00ffc2..35d71092 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/model/verto/send/modify_message_body.dart'; import 'package:telnyx_webrtc/peer/session.dart'; import 'package:telnyx_webrtc/peer/signaling_state.dart'; @@ -16,6 +18,7 @@ import 'package:telnyx_webrtc/utils/codec_utils.dart'; import 'package:telnyx_webrtc/utils/logging/global_logger.dart'; import 'package:telnyx_webrtc/utils/stats/webrtc_stats_reporter.dart'; import 'package:telnyx_webrtc/utils/string_utils.dart'; +import 'package:telnyx_webrtc/utils/sdp_utils.dart'; import 'package:telnyx_webrtc/utils/version_utils.dart'; import 'package:uuid/uuid.dart'; import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; @@ -30,7 +33,13 @@ 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); @@ -38,14 +47,23 @@ class Peer { final TelnyxClient _txClient; final bool _debug; final bool _forceRelayCandidate; + final bool _useTrickleIce; WebRTCStatsReporter? _statsManager; // 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 + Timer? _trickleIceTimer; + DateTime? _lastTrickleCandidateTime; + static const int _trickleIceTimeout = + 3000; // 3 seconds timeout for trickle ICE + String? _currentTrickleCallId; + bool _endOfCandidatesSent = false; + final Map _sessions = {}; /// Current active session @@ -228,45 +246,39 @@ class Peer { ); } - final RTCSessionDescription s = await session.peerConnection!.createOffer( - _dcConstraints, - ); - - // For Android: Modify SDP to filter codecs - String? sdpToUse = s.sdp; - if (preferredCodecs != null && - preferredCodecs.isNotEmpty && - Platform.isAndroid) { - GlobalLogger().d( - 'Peer :: Filtering SDP codecs for Android (setCodecPreferences not supported)', + // 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( + _dcConstraints, ); - final audioCodecs = - preferredCodecs.map((m) => AudioCodec.fromJson(m)).toList(); - sdpToUse = CodecUtils.filterSdpCodecs(s.sdp!, audioCodecs); - } - - await session.peerConnection!.setLocalDescription( - RTCSessionDescription(sdpToUse, s.type), - ); - if (session.remoteCandidates.isNotEmpty) { - for (var candidate in session.remoteCandidates) { - if (candidate.candidate != null) { - GlobalLogger().i('adding $candidate'); - await session.peerConnection?.addCandidate(candidate); - } + // For Android: Modify SDP to filter codecs + String? sdpToUse = s.sdp; + if (preferredCodecs != null && + preferredCodecs.isNotEmpty && + Platform.isAndroid) { + GlobalLogger().d( + 'Peer :: Filtering SDP codecs for Android (setCodecPreferences not supported)', + ); + final audioCodecs = + preferredCodecs.map((m) => AudioCodec.fromJson(m)).toList(); + sdpToUse = CodecUtils.filterSdpCodecs(s.sdp!, audioCodecs); } - session.remoteCandidates.clear(); - } - await Future.delayed(const Duration(milliseconds: 500)); + // For trickle ICE, we set the local description but don't wait for candidates + await session.peerConnection!.setLocalDescription( + RTCSessionDescription(sdpToUse, s.type), + ); - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); + // 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); - Timer(const Duration(milliseconds: 500), () async { final userAgent = VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, @@ -289,6 +301,7 @@ class Peer { sdp: sdpUsed, sessid: sessionId, userAgent: userAgent, + trickle: true, // Set trickle flag ); final inviteMessage = InviteAnswerMessage( id: const Uuid().v4(), @@ -298,9 +311,70 @@ class Peer { ); final String jsonInviteMessage = jsonEncode(inviteMessage); - + GlobalLogger().i( + 'Peer :: Sending INVITE with trickle ICE enabled (no candidate gathering)', + ); _send(jsonInviteMessage); - }); + } else { + // Traditional ICE gathering - use negotiation timer + 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(); + } + + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); + + final userAgent = 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'); } @@ -313,6 +387,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. @@ -349,6 +439,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', @@ -382,21 +489,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 @@ -404,18 +521,20 @@ class Peer { } }; - final RTCSessionDescription s = - await session.peerConnection!.createAnswer(_dcConstraints); + if (_useTrickleIce) { + // With trickle ICE, create answer without waiting for ICE gathering + final RTCSessionDescription s = + await session.peerConnection!.createAnswer(_dcConstraints); - await session.peerConnection!.setLocalDescription(s); + // For trickle ICE, we set the local description but don't wait for candidates + await session.peerConnection!.setLocalDescription(s); - // Start ICE candidate gathering and wait for negotiation to complete - _lastCandidateTime = DateTime.now(); - _setOnNegotiationComplete(() async { - String? sdpUsed = ''; - await session.peerConnection?.getLocalDescription().then( - (value) => sdpUsed = value?.sdp.toString(), - ); + // 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); final userAgent = VersionUtils.getUserAgent(); final dialogParams = DialogParams( @@ -438,6 +557,7 @@ class Peer { sdp: sdpUsed, sessid: session.sid, userAgent: userAgent, + trickle: true, // Set trickle flag ); final answerMessage = InviteAnswerMessage( id: const Uuid().v4(), @@ -447,8 +567,56 @@ 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 + final RTCSessionDescription s = + await session.peerConnection!.createAnswer(_dcConstraints); + await session.peerConnection!.setLocalDescription(s); + + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(() async { + String? sdpUsed = ''; + await session.peerConnection?.getLocalDescription().then( + (value) => sdpUsed = value?.sdp.toString(), + ); + + final userAgent = 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, + 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'); } @@ -534,21 +702,38 @@ 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'); - // Add valid candidates - await peerConnection?.addCandidate(candidate); - } else { + if (_useTrickleIce) { + // With trickle ICE, send ALL candidates immediately (host, srflx, relay) GlobalLogger().i( - 'Peer :: Ignoring non-STUN/TURN candidate: $candidateString', + '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(); + 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 :: onIceCandidate: complete!'); + if (_useTrickleIce) { + // Send end of candidates signal when gathering completes naturally + _sendEndOfCandidatesAndCleanup(callId); + } } }; @@ -566,7 +751,8 @@ class Peer { Future.delayed(const Duration(milliseconds: 100), () { if (currentCall?.isReconnection == true) { // This is a reconnection - restore previous speakerphone state - final bool shouldEnableSpeaker = currentCall?.speakerPhone ?? false; + final bool shouldEnableSpeaker = + currentCall?.speakerPhone ?? false; currentCall?.enableSpeakerPhone(shouldEnableSpeaker); GlobalLogger().i( 'Peer :: Restored speakerphone state for Android reconnection: $shouldEnableSpeaker', @@ -718,6 +904,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 @@ -755,6 +944,165 @@ 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', + ); + } + } + + /// 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 ?? '0', // Default to '0' for audio m-line + sdpMLineIndex: + candidate.sdpMLineIndex ?? 0, // Default to 0 for audio m-line + ); + + 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} (sdpMid: ${candidateParams.sdpMid}, sdpMLineIndex: ${candidateParams.sdpMLineIndex})', + ); + _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'); + } + } + + /// 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'); + } + } + /// Starts ICE renegotiation process when ICE connection fails Future startIceRenegotiation(String callId, String sessionId) async { try { diff --git a/packages/telnyx_webrtc/lib/peer/web/peer.dart b/packages/telnyx_webrtc/lib/peer/web/peer.dart index 8d47398e..ec84bb48 100644 --- a/packages/telnyx_webrtc/lib/peer/web/peer.dart +++ b/packages/telnyx_webrtc/lib/peer/web/peer.dart @@ -11,6 +11,8 @@ 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/receive/update_media_response.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/model/verto/send/modify_message_body.dart'; import 'package:telnyx_webrtc/peer/session.dart'; import 'package:telnyx_webrtc/peer/signaling_state.dart'; @@ -20,19 +22,30 @@ import 'package:telnyx_webrtc/tx_socket.dart' import 'package:telnyx_webrtc/utils/codec_utils.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'; /// 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); @@ -40,9 +53,17 @@ 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 + 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 = {}; @@ -84,7 +105,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, }, @@ -223,6 +249,26 @@ class Peer { List>? preferredCodecs, ) async { try { + // For trickle ICE, set up immediate candidate sending first + if (_useTrickleIce) { + session.peerConnection?.onIceCandidate = (candidate) async { + if (candidate.candidate != null) { + GlobalLogger().i( + '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 (natural completion)', + ); + _sendEndOfCandidatesAndCleanup(callId); + } + }; + } + // Apply codec preferences before creating offer if (preferredCodecs != null && preferredCodecs.isNotEmpty) { await applyAudioCodecPreferences( @@ -231,11 +277,6 @@ class Peer { ); } - final description = await session.peerConnection!.createOffer( - _dcConstraints, - ); - await session.peerConnection!.setLocalDescription(description); - // Add any remote candidates that arrived early if (session.remoteCandidates.isNotEmpty) { for (var candidate in session.remoteCandidates) { @@ -247,18 +288,41 @@ class Peer { session.remoteCandidates.clear(); } - // Give the localDescription a moment to be set - await Future.delayed(const Duration(milliseconds: 500)); - 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 - Timer(const Duration(milliseconds: 500), () async { - final userAgent = VersionUtils.getUserAgent(); + // Send INVITE immediately for trickle ICE, or after delay for regular ICE + Future sendInvite() async { + final userAgent = await VersionUtils.getUserAgent(); final dialogParams = DialogParams( attach: false, audio: true, @@ -280,6 +344,7 @@ class Peer { sdp: sdpUsed, sessid: session.sid, userAgent: userAgent, + trickle: _useTrickleIce, ); final inviteMessage = InviteAnswerMessage( @@ -291,7 +356,16 @@ class Peer { final String jsonInviteMessage = jsonEncode(inviteMessage); _send(jsonInviteMessage); - }); + } + + if (_useTrickleIce) { + // Send INVITE immediately for trickle ICE + await sendInvite(); + } else { + // Traditional ICE gathering - use negotiation timer + _lastCandidateTime = DateTime.now(); + _setOnNegotiationComplete(sendInvite); + } } catch (e) { GlobalLogger().e('Peer :: _createOffer error: $e'); } @@ -306,6 +380,26 @@ 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); } } @@ -320,15 +414,14 @@ class Peer { /// [customHeaders] Custom headers to include in the answer. /// [isAttach] Whether this is an attach call. Future accept( - String callerName, - String callerNumber, - String destinationNumber, - String clientState, - String callId, - IncomingInviteParams invite, - Map customHeaders, - bool isAttach, - ) 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, @@ -345,6 +438,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, @@ -373,133 +484,169 @@ class Peer { bool isAttach, ) 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); + + // Reset the trickle ICE timer when a candidate is generated + _startTrickleIceTimer(callId); } else { GlobalLogger().i( - 'Web Peer :: Ignoring non-STUN/TURN candidate: $candidateString', + 'Trickle ICE: Sending end of candidates (natural completion)', ); + _sendEndOfCandidatesAndCleanup(callId); } - } else { - GlobalLogger().i('Web Peer :: onIceCandidate: complete'); - } - }; - - session.peerConnection?.onIceConnectionState = (state) { - GlobalLogger().i('Web Peer :: ICE Connection State change :: $state'); - _previousIceConnectionState = state; - switch (state) { - case RTCIceConnectionState.RTCIceConnectionStateConnected: - final Call? currentCall = _txClient.calls[callId]; - currentCall?.callHandler.changeState(CallState.active); - onCallStateChange?.call(session, CallState.active); - - // Restore speakerphone state after ICE connection is established - // This is important for network reconnection scenarios where the call state should be preserved - final bool shouldEnableSpeaker = currentCall?.speakerPhone ?? false; - if (shouldEnableSpeaker) { - Future.delayed(const Duration(milliseconds: 100), () { - currentCall?.enableSpeakerPhone(true); - GlobalLogger().i( - 'Web Peer :: Restored speakerphone state in _createAnswer: enabled', - ); - }); - } + }; + } 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'); - // Cancel any reconnection timer for this call - _txClient.onCallStateChangedToActive(callId); - case RTCIceConnectionState.RTCIceConnectionStateFailed: - if (_previousIceConnectionState == - RTCIceConnectionState.RTCIceConnectionStateDisconnected) { + if (isValidCandidate) { GlobalLogger().i( - 'Web Peer :: ICE connection failed, starting renegotiation...', + 'Web Peer :: Valid ICE candidate: $candidateString', ); - startIceRenegotiation(callId, session.sid); - break; + // Only add valid candidates and reset timer + await session.peerConnection?.addCandidate(candidate); + _lastCandidateTime = DateTime.now(); } else { - GlobalLogger().d( - 'Web Peer :: ICE connection failed without prior disconnection, not renegotiating', + GlobalLogger().i( + 'Web Peer :: Ignoring non-STUN/TURN candidate: $candidateString', ); - break; } - case RTCIceConnectionState.RTCIceConnectionStateDisconnected: - _statsManager?.stopStatsReporting(); - return; - default: - return; - } - }; - - // Create and set local description - final description = await session.peerConnection!.createAnswer( - _dcConstraints, - ); - 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; - } + } else { + GlobalLogger().i('Web Peer :: onIceCandidate: complete'); + } + }; + } - final userAgent = 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, + // Handle answer creation based on trickle ICE mode + if (_useTrickleIce) { + // For trickle ICE, create answer without waiting for ICE gathering + final description = await session.peerConnection!.createAnswer( + _dcConstraints, ); - final inviteParams = InviteParams( - dialogParams: dialogParams, - sdp: sdpUsed, - sessid: session.sid, // We use the session's sid - userAgent: userAgent, + // 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); + + // For trickle ICE, send answer immediately + await _sendAnswerMessage( + session, + callId, + callerNumber, + destinationNumber, + clientState, + customHeaders, + isAttach, + modifiedSdp, // Pass the SDP directly to avoid getting it later ); - - final answerMessage = InviteAnswerMessage( - id: const Uuid().v4(), - jsonrpc: JsonRPCConstant.jsonrpc, - method: isAttach ? SocketMethod.attach : SocketMethod.answer, - params: inviteParams, + } else { + // Traditional ICE gathering + final description = await session.peerConnection!.createAnswer( + _dcConstraints, ); + await session.peerConnection!.setLocalDescription(description); - final String jsonAnswerMessage = jsonEncode(answerMessage); - _send(jsonAnswerMessage); - }); + // 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, + ); + }); + } } 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, [ + String? preGeneratedSdp, + ]) async { + String? sdpUsed = ''; + + // 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(); + 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). @@ -587,7 +734,7 @@ class Peer { } } - // ICE callbacks + // ICE callbacks - this will be overridden in _createOffer/_createAnswer for trickle ICE pc ..onIceCandidate = (candidate) async { GlobalLogger().i( @@ -769,6 +916,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) { @@ -810,6 +960,139 @@ 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', + ); + } + } + + /// 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', + ); + } + } + } + /// Starts ICE renegotiation process when ICE connection fails Future startIceRenegotiation(String callId, String sessionId) async { try { diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index 37cea2cc..e20799f1 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -43,6 +43,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'; import 'package:telnyx_webrtc/model/socket_connection_metrics.dart'; /// Callback for when the socket receives a message @@ -58,7 +60,8 @@ typedef OnTranscriptUpdate = void Function(List transcript); typedef OnConnectionStateChanged = void Function(ConnectionStatus status); /// Callback for when connection metrics are updated -typedef OnConnectionMetricsUpdate = void Function(SocketConnectionMetrics metrics); +typedef OnConnectionMetricsUpdate = void Function( + SocketConnectionMetrics metrics); /// Represents the main entry point for interacting with the Telnyx RTC SDK. /// @@ -182,6 +185,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() { @@ -553,6 +560,85 @@ 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( @@ -1323,6 +1409,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( @@ -1333,6 +1423,7 @@ class TelnyxClient { Map customHeaders = const {}, List? preferredCodecs, bool debug = false, + bool useTrickleIce = false, }) { final Call inviteCall = _createCall() ..sessionCallerName = callerName @@ -1350,6 +1441,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), + useTrickleIce, ); // Convert AudioCodec objects to Map format for the peer connection List>? codecMaps; @@ -1388,6 +1480,10 @@ class TelnyxClient { /// - [isAttach]: Set to true if this is a call being re-attached (e.g., after network reconnection). /// - [customHeaders]: Optional custom SIP headers to add to the response. /// - [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( @@ -1398,6 +1494,7 @@ class TelnyxClient { bool isAttach = false, Map customHeaders = const {}, bool debug = false, + bool useTrickleIce = false, }) { final Call answerCall = getCallOrNull(invite.callID!) ?? _createCall() ..callId = invite.callID @@ -1415,6 +1512,7 @@ class TelnyxClient { debug || _debug, this, getForceRelayCandidate(), + useTrickleIce, ); // Set up the session with the callback if debug is enabled @@ -1910,13 +2008,16 @@ class TelnyxClient { // Preserve speakerphone state from existing call before reconnection final existingCall = calls[invite.inviteParams?.callID]; - final bool wasSpeakerPhoneEnabled = existingCall?.speakerPhone ?? false; - GlobalLogger().i('ATTACH :: Preserving speakerphone state: $wasSpeakerPhoneEnabled'); + final bool wasSpeakerPhoneEnabled = + existingCall?.speakerPhone ?? false; + GlobalLogger().i( + 'ATTACH :: Preserving speakerphone state: $wasSpeakerPhoneEnabled'); //play ringtone for web final Call offerCall = _createCall() ..callId = invite.inviteParams?.callID - ..speakerPhone = wasSpeakerPhoneEnabled; // Preserve the state + ..speakerPhone = + wasSpeakerPhoneEnabled; // Preserve the state updateCall(offerCall); onSocketMessageReceived.call(message); @@ -2092,6 +2193,10 @@ class TelnyxClient { socketMethod: SocketMethod.ringing, message: ringing, ); + + // Process any queued ICE candidates after remote description is set (Android-style approach) + _processQueuedIceCandidates(ringing.inviteParams!.callID!); + onSocketMessageReceived(message); break; } @@ -2121,6 +2226,74 @@ class TelnyxClient { onSocketMessageReceived(message); break; } + case SocketMethod.candidate: + { + GlobalLogger() + .i('TRICKLE ICE CANDIDATE RECEIVED :: $messageJson'); + final Map candidateData = + jsonDecode(data.toString()); + + // Extract params from the candidate data + final Map? params = candidateData['params']; + if (params == null) { + GlobalLogger().w('Candidate message missing params'); + break; + } + + // 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: + { + 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'); + } else { + GlobalLogger().w( + 'Received endOfCandidates for unknown call: $callId'); + } + } + break; + } } } else { GlobalLogger().i('Received and ignored empty packet'); diff --git a/packages/telnyx_webrtc/lib/tx_socket.dart b/packages/telnyx_webrtc/lib/tx_socket.dart index 24741056..d3dfe0ce 100644 --- a/packages/telnyx_webrtc/lib/tx_socket.dart +++ b/packages/telnyx_webrtc/lib/tx_socket.dart @@ -88,7 +88,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 e4ec1029..652ece1b 100644 --- a/packages/telnyx_webrtc/lib/tx_socket_web.dart +++ b/packages/telnyx_webrtc/lib/tx_socket_web.dart @@ -84,7 +84,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'); } 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 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