MT#63734 Activate ICE configuration

Trickle ICE candidate updates were not sent through
to kamailio/rtpengine. This was happening because
there was not ICE configuration.

Change-Id: I2a3ae1ab92ac9d6e766bd46915930fd553a6ff26
(cherry picked from commit ed162e8e56)
(cherry picked from commit a4c8a69cee)
mr13.5.1
Debora Crescenzo 8 months ago committed by Crescenzo Debora
parent 6816e17979
commit f0e3349f54

@ -13,6 +13,8 @@ let $localMediaStream = null
let $remoteMediaStream = null let $remoteMediaStream = null
let $videoTransceiver = null let $videoTransceiver = null
let $audioTransceiver = null let $audioTransceiver = null
let $iceServers = []
let $pendingIceCandidates = []
const TERMINATION_OPTIONS = { const TERMINATION_OPTIONS = {
status_code: 603, status_code: 603,
@ -97,19 +99,140 @@ function getSubscriberUri () {
return `sip:${$subscriber.username}@${$subscriber.domain}` return `sip:${$subscriber.username}@${$subscriber.domain}`
} }
function createTrickleIceSdpFragment (candidateInfo) {
/**
* Convert ICE candidate to SDP fragment format (RFC 8840)
* Must include m= line, a=mid: line, and a=candidate: line
* RFC Rules:
* The media field is set to 'audio'.
* The port value is set to '9'.
* The proto value is set to 'RTP/AVP'.
* The fmt field MUST appear only once and is set to '0'.
* Example output:
* m=audio 9 RTP/AVP 0
* a=mid:0
* a=candidate:foundation 1 udp 2113667326 192.168.1.100 54400 typ host
*/
let sdpFragment = ''
sdpFragment += 'm=audio 9 RTP/AVP 0\r\n'
// Include media ID if we have it
if (candidateInfo.sdpMid) {
sdpFragment += `a=mid:${candidateInfo.sdpMid}\r\n`
}
// The actual ICE candidate
sdpFragment += `a=${candidateInfo.candidate}`
return sdpFragment
}
/**
* Send any ICE candidates we collected before the call was fully established.
*
* Problem: ICE candidates start showing up immediately when we create the peer connection,
* but we can't send SIP INFO messages until the call is actually confirmed. So we queue
* them up and send them all at once when the call is ready.
*
* This fixes issues with video calls where we get important candidates
* after the initial call setup.
*/
function sendQueuedIceCandidates (rtcSession) {
if ($pendingIceCandidates.length === 0) {
return
}
const candidates = [...$pendingIceCandidates]
$pendingIceCandidates = []
candidates.forEach((candidateInfo, index) => {
try {
const sdpFragment = createTrickleIceSdpFragment(candidateInfo)
rtcSession.sendInfo('application/trickle-ice-sdpfrag', sdpFragment, {
extraHeaders: [
'Info-Package: trickle-ice',
'Content-Disposition: Info-Package'
],
eventHandlers: {
succeeded: () => {
callEvent.emit('iceCandidateSent', candidateInfo)
},
failed: (e) => {
callEvent.emit('iceCandidateFailed', { candidate: candidateInfo, error: e })
}
}
})
} catch (error) {
callEvent.emit('iceCandidateFailed', { candidate: candidateInfo, error })
}
})
}
function handleIceCandidate (event, rtcSession) {
if (event.candidate) {
// Got a new ICE candidate - send it to the other side via SIP INFO
try {
const candidateInfo = {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
}
/**
* Status 9 = confirmed, 12 = ended
*
* Why did we include 12?
* It handles the gap between "SIP says the call is over"
* and "WebRTC finishes cleaning up the connection."
*/
if (rtcSession.status === 9 || rtcSession.status === 12) {
const sdpFragment = createTrickleIceSdpFragment(candidateInfo)
rtcSession.sendInfo('application/trickle-ice-sdpfrag', sdpFragment, {
extraHeaders: [
'Info-Package: trickle-ice',
'Content-Disposition: Info-Package'
],
eventHandlers: {
succeeded: () => {
callEvent.emit('iceCandidateSent', candidateInfo)
},
failed: (e) => {
callEvent.emit('iceCandidateFailed', { candidate: candidateInfo, error: e })
}
}
})
} else {
$pendingIceCandidates.push(candidateInfo)
}
} catch (error) {
callEvent.emit('iceCandidateError', error)
}
} else {
callEvent.emit('iceGatheringComplete')
}
}
// WebSocket Authentication to Kamailio
function callCreateSocket () { function callCreateSocket () {
return new jssip.WebSocketInterface(`${$baseWebSocketUrl}/${$subscriber.username}`) return new jssip.WebSocketInterface(`${$baseWebSocketUrl}/${$subscriber.username}`)
} }
export function callConfigure ({ baseWebSocketUrl }) { export function callConfigure ({ baseWebSocketUrl, iceServers }) {
$baseWebSocketUrl = baseWebSocketUrl $baseWebSocketUrl = baseWebSocketUrl
if (iceServers && Array.isArray(iceServers)) {
$iceServers = iceServers
}
} }
export async function callInitialize ({ subscriber, instanceId }) { export async function callInitialize ({ subscriber, instanceId }) {
$subscriber = subscriber $subscriber = subscriber
callRegister({ instanceId }) callRegister({ instanceId })
} }
// Set up SIP connection and register with server
export function callRegister ({ instanceId }) { export function callRegister ({ instanceId }) {
if (!$socket) { if (!$socket) {
$socket = callCreateSocket() $socket = callCreateSocket()
@ -117,7 +240,14 @@ export function callRegister ({ instanceId }) {
sockets: [$socket], sockets: [$socket],
uri: getSubscriberUri(), uri: getSubscriberUri(),
password: $subscriber.password, password: $subscriber.password,
instance_id: instanceId instance_id: instanceId,
// WebRTC settings for proper ICE handling
pcConfig: {
iceServers: $iceServers,
iceCandidatePoolSize: 10,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
}
} }
$userAgent = new jssip.UA(config) $userAgent = new jssip.UA(config)
const delegateEvent = (eventName) => { const delegateEvent = (eventName) => {
@ -130,6 +260,59 @@ export function callRegister ({ instanceId }) {
delegateEvent('registered') delegateEvent('registered')
delegateEvent('unregistered') delegateEvent('unregistered')
delegateEvent('registrationFailed') delegateEvent('registrationFailed')
// Handle incoming ICE candidates via SIP INFO messages
$userAgent.on('newMessage', (event) => {
if (event.request.getHeader('Content-Type') === 'application/trickle-ice-sdpfrag') {
try {
const sdpFragment = event.request.body
// Extract candidate from SDP fragment format: a=candidate:...
const candidateMatch = sdpFragment.match(/a=candidate:(.+)/)
if (candidateMatch) {
const candidateString = `candidate:${candidateMatch[1]}`
// Extract sdpMid from a=mid: line
const midMatch = sdpFragment.match(/a=mid:(.+)/)
const sdpMid = midMatch ? midMatch[1].trim() : null
// Extract sdpMLineIndex from m= line position
// Parse media type to determine line index (audio=0, video=1)
const mediaMatch = sdpFragment.match(/m=(\w+)/)
let sdpMLineIndex = null
if (mediaMatch) {
const mediaType = mediaMatch[1]
sdpMLineIndex = mediaType === 'video' ? 1 : 0
}
const rtcSession = callGetRtcSession()
if (rtcSession && rtcSession.connection) {
const candidate = new RTCIceCandidate({
candidate: candidateString,
sdpMid,
sdpMLineIndex
})
rtcSession.connection.addIceCandidate(candidate)
.then(() => {
callEvent.emit('iceCandidateReceived', {
candidate: candidateString,
sdpMid,
sdpMLineIndex
})
})
.catch((error) => {
callEvent.emit('iceCandidateAddError', error)
})
}
}
event.reply(200, 'OK')
} catch (error) {
callEvent.emit('iceCandidateParseError', error)
event.reply(400, 'Bad Request')
}
}
})
$userAgent.on('newRTCSession', (event) => { $userAgent.on('newRTCSession', (event) => {
if (event.originator === 'remote') { if (event.originator === 'remote') {
if ($incomingRtcSession || $outgoingRtcSession) { if ($incomingRtcSession || $outgoingRtcSession) {
@ -141,6 +324,10 @@ export function callRegister ({ instanceId }) {
$incomingRtcSession = event.session $incomingRtcSession = event.session
$incomingRtcSession.on('peerconnection', () => { $incomingRtcSession.on('peerconnection', () => {
$incomingRtcSession.connection.ontrack = handleRemoteMediaStream $incomingRtcSession.connection.ontrack = handleRemoteMediaStream
// Handle trickle ICE for incoming calls
$incomingRtcSession.connection.onicecandidate = (candidateEvent) => {
handleIceCandidate(candidateEvent, $incomingRtcSession)
}
}) })
$incomingRtcSession.on('failed', (failedEvent) => { $incomingRtcSession.on('failed', (failedEvent) => {
callEvent.emit('incomingFailed', failedEvent) callEvent.emit('incomingFailed', failedEvent)
@ -174,6 +361,7 @@ export function callUnregister () {
} }
} }
// SIP INVITE - Start call request
export async function callStart ({ number }) { export async function callStart ({ number }) {
try { try {
$localMediaStream = await callCreateLocalAudioStream() $localMediaStream = await callCreateLocalAudioStream()
@ -192,6 +380,8 @@ export async function callStart ({ number }) {
}, },
confirmed (event) { confirmed (event) {
callEvent.emit('outgoingConfirmed', event) callEvent.emit('outgoingConfirmed', event)
// Send any queued ICE candidates now that session is confirmed
sendQueuedIceCandidates($outgoingRtcSession)
}, },
ended (event) { ended (event) {
callEvent.emit('outgoingEnded', event) callEvent.emit('outgoingEnded', event)
@ -219,6 +409,10 @@ export async function callStart ({ number }) {
mediaStream: $localMediaStream mediaStream: $localMediaStream
}) })
$outgoingRtcSession.connection.ontrack = handleRemoteMediaStream $outgoingRtcSession.connection.ontrack = handleRemoteMediaStream
// Handle trickle ICE for outgoing calls
$outgoingRtcSession.connection.onicecandidate = (candidateEvent) => {
handleIceCandidate(candidateEvent, $outgoingRtcSession)
}
return true return true
} catch (e) { } catch (e) {
return false return false
@ -226,12 +420,16 @@ export async function callStart ({ number }) {
} }
export async function callAccept () { export async function callAccept () {
$localMediaStream = await callCreateLocalAudioStream() try {
callEvent.emit('localStream', $localMediaStream) $localMediaStream = await callCreateLocalAudioStream()
if ($incomingRtcSession) { callEvent.emit('localStream', $localMediaStream)
$incomingRtcSession.answer({ if ($incomingRtcSession) {
mediaStream: $localMediaStream $incomingRtcSession.answer({
}) mediaStream: $localMediaStream
})
}
} catch (e) {
callEvent.emit('incomingFailed', { cause: e })
} }
} }
@ -280,7 +478,7 @@ async function callStopVideo () {
} }
} }
export async function callSendVideo (stream, audioMuted) { export async function callSendVideo (stream) {
const videoTrack = stream.getVideoTracks()[0] const videoTrack = stream.getVideoTracks()[0]
if ($videoTransceiver?.sender?.track) { if ($videoTransceiver?.sender?.track) {
$localMediaStream.removeTrack($videoTransceiver.sender.track) $localMediaStream.removeTrack($videoTransceiver.sender.track)
@ -308,11 +506,20 @@ export async function callAddCamera () {
} }
export async function callAddScreen () { export async function callAddScreen () {
$isVideoScreen = true const originalVideoScreenState = $isVideoScreen
await callSendVideo(await navigator.mediaDevices.getDisplayMedia({ try {
video: callGetScreenConstraints(), const stream = await navigator.mediaDevices.getDisplayMedia({
audio: false video: callGetScreenConstraints(),
})) audio: false
})
$isVideoScreen = true
await callSendVideo(stream)
} catch (error) {
// Reset state to original value if screen sharing fails
// and throw a error that can be displayed in UI
$isVideoScreen = originalVideoScreenState
throw error
}
} }
export async function callRemoveVideo () { export async function callRemoveVideo () {

@ -99,13 +99,17 @@ export default {
} }
}, },
async toggleScreen (context) { async toggleScreen (context) {
if (!callHasLocalVideo() || callHasLocalCamera()) { try {
await callAddScreen() if (!callHasLocalVideo() || callHasLocalCamera()) {
context.commit('disableVideo') await callAddScreen()
context.commit('enableScreen') context.commit('disableVideo')
} else { context.commit('enableScreen')
await callRemoveVideo() } else {
context.commit('disableVideo') await callRemoveVideo()
context.commit('disableVideo')
}
} catch (error) {
showGlobalError(error.message || i18n.global.t('Failed to toggle screen sharing'))
} }
}, },
end (context, options = { cause: null }) { end (context, options = { cause: null }) {

Loading…
Cancel
Save